From 3f55cf5ddf37cd169140a3ab6819a2c0d265a006 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 28 Mar 2025 18:41:54 +0000 Subject: [PATCH 01/17] feat(core): Add Supabase Queues support --- .../integrations/supabase/queues-rpc/init.js | 34 ++++++++ .../integrations/supabase/queues-rpc/test.ts | 64 ++++++++++++++ .../supabase/queues-schema/init.js | 38 ++++++++ .../supabase/queues-schema/test.ts | 63 ++++++++++++++ packages/core/src/integrations/supabase.ts | 86 +++++++++++++++++-- 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js new file mode 100644 index 000000000000..7b0fdb096ebc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/browser'; + +import { createClient } from '@supabase/supabase-js'; +window.Sentry = Sentry; + +const queues = createClient('https://test.supabase.co', 'test-key', { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration(queues)], + tracesSampleRate: 1.0, +}); + +// Simulate queue operations +async function performQueueOperations() { + try { + await queues.rpc('enqueue', { + queue_name: 'todos', + msg: { title: 'Test Todo' }, + }); + + await queues.rpc('dequeue', { + queue_name: 'todos', + }); + } catch (error) { + Sentry.captureException(error); + } +} + +performQueueOperations(); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts new file mode 100644 index 000000000000..8b6ee89e9f81 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts @@ -0,0 +1,64 @@ +import type { Page} from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +async function mockSupabaseRoute(page: Page) { + await page.route('**/rest/v1/rpc**', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + foo: ['bar', 'baz'], + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +} + +sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLocalTestUrl, page }) => { + await mockSupabaseRoute(page); + + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const event = await getFirstSentryEnvelopeRequest(page, url); + const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue')); + + expect(queueSpans).toHaveLength(2); + + expect(queueSpans![0]).toMatchObject({ + description: 'supabase.db.rpc', + parent_span_id: event.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: event.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.db.supabase', + 'messaging.destination.name': 'todos', + 'messaging.message.id': 'Test Todo', + }), + }); + + expect(queueSpans![1]).toMatchObject({ + description: 'supabase.db.rpc', + parent_span_id: event.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: event.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase', + 'messaging.destination.name': 'todos', + }), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js new file mode 100644 index 000000000000..43c50357f1eb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/browser'; + +import { createClient } from '@supabase/supabase-js'; +window.Sentry = Sentry; + +const queues = createClient('https://test.supabase.co', 'test-key', { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration(queues)], + tracesSampleRate: 1.0, +}); + +// Simulate queue operations +async function performQueueOperations() { + try { + await queues + .schema('pgmq_public') + .rpc('enqueue', { + queue_name: 'todos', + msg: { title: 'Test Todo' }, + }); + + await queues + .schema('pgmq_public') + .rpc('dequeue', { + queue_name: 'todos', + }); + } catch (error) { + Sentry.captureException(error); + } +} + +performQueueOperations(); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts new file mode 100644 index 000000000000..8070a1b17357 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts @@ -0,0 +1,63 @@ +import { type Page, expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +async function mockSupabaseRoute(page: Page) { + await page.route('**/rest/v1/rpc**', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + foo: ['bar', 'baz'], + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +} + +sentryTest('should capture Supabase queue spans from client.schema(...).rpc', async ({ getLocalTestUrl, page }) => { + await mockSupabaseRoute(page); + + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const event = await getFirstSentryEnvelopeRequest(page, url); + const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue')); + + expect(queueSpans).toHaveLength(2); + + expect(queueSpans![0]).toMatchObject({ + description: 'supabase.db.rpc', + parent_span_id: event.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: event.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.db.supabase', + 'messaging.destination.name': 'todos', + 'messaging.message.id': 'Test Todo', + }), + }); + + expect(queueSpans![1]).toMatchObject({ + description: 'supabase.db.rpc', + parent_span_id: event.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: event.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase', + 'messaging.destination.name': 'todos', + }), + }); +}); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 61005fdad805..6ab7f21c31b0 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -13,6 +13,14 @@ import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; +export interface SupabaseClientConstructor { + prototype: { + from: (table: string) => PostgRESTQueryBuilder; + schema: (schema: string) => { rpc: (...args: unknown[]) => Promise }; + }; + rpc: (fn: string, params: Record) => Promise; +} + const AUTH_OPERATIONS_TO_INSTRUMENT = [ 'reauthenticate', 'signInAnonymously', @@ -114,12 +122,6 @@ export interface SupabaseBreadcrumb { }; } -export interface SupabaseClientConstructor { - prototype: { - from: (table: string) => PostgRESTQueryBuilder; - }; -} - export interface PostgRESTProtoThenable { then: ( onfulfilled?: ((value: T) => T | PromiseLike) | null, @@ -215,6 +217,76 @@ export function translateFiltersIntoMethods(key: string, query: string): string return `${method}(${key}, ${value.join('.')})`; } +function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { + (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema = new Proxy( + (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema, + { + apply(target, thisArg, argumentsList) { + const rv = Reflect.apply(target, thisArg, argumentsList); + + return instrumentRpc(rv); + }, + }, + ); +} + +function instrumentRpc(SupabaseClient: unknown): unknown { + (SupabaseClient as unknown as SupabaseClientConstructor).rpc = new Proxy( + (SupabaseClient as unknown as SupabaseClientConstructor).rpc, + { + apply(target, thisArg, argumentsList) { + const isProducerSpan = argumentsList[0] === 'enqueue'; + const isConsumerSpan = argumentsList[0] === 'dequeue'; + + const maybeQueueParams = argumentsList[1]; + + // If the second argument is not an object, it's not a queue operation + if (!isPlainObject(maybeQueueParams)) { + return Reflect.apply(target, thisArg, argumentsList); + } + + const msg = maybeQueueParams?.msg as { title: string }; + + const messageId = msg?.title; + const queueName = maybeQueueParams?.queue_name as string; + + const op = isProducerSpan ? 'queue.publish' : isConsumerSpan ? 'queue.process' : ''; + + // If the operation is not a queue operation, return the original function + if (!op) { + return Reflect.apply(target, thisArg, argumentsList); + } + + return startSpan( + { + name: 'supabase.db.rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + }, + }, + async span => { + return (Reflect.apply(target, thisArg, argumentsList) as Promise).then((res: unknown) => { + if (messageId) { + span.setAttribute('messaging.message.id', messageId); + } + + if (queueName) { + span.setAttribute('messaging.destination.name', queueName); + } + + span.end(); + return res; + }); + }, + ); + }, + }, + ); + + return SupabaseClient; +} + function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn { return new Proxy(operation, { apply(target, thisArg, argumentsList) { @@ -516,6 +588,8 @@ export const instrumentSupabaseClient = (supabaseClient: unknown): void => { supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor; instrumentSupabaseClientConstructor(SupabaseClientConstructor); + instrumentRpcReturnedFromSchemaCall(SupabaseClientConstructor); + instrumentRpc(supabaseClient as SupabaseClientInstance); instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance); }; From ecf67cc1235db87908c0b512b2d00346b21d32c9 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 22 Apr 2025 15:31:06 +0100 Subject: [PATCH 02/17] Skip tests on bundles --- .../suites/integrations/supabase/queues-rpc/test.ts | 6 ++++++ .../suites/integrations/supabase/queues-schema/test.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts index 8b6ee89e9f81..2bd3f9bd4b1e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts @@ -19,6 +19,12 @@ async function mockSupabaseRoute(page: Page) { }); } +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLocalTestUrl, page }) => { await mockSupabaseRoute(page); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts index 8070a1b17357..c08022acaa47 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts @@ -18,6 +18,12 @@ async function mockSupabaseRoute(page: Page) { }); } +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + sentryTest('should capture Supabase queue spans from client.schema(...).rpc', async ({ getLocalTestUrl, page }) => { await mockSupabaseRoute(page); From 13d4a6e5f870966e2f6dc3f549a5499d42353646 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 22 Apr 2025 16:30:35 +0100 Subject: [PATCH 03/17] Update test usage --- .../integrations/supabase/queues-rpc/init.js | 18 +++++++-------- .../supabase/queues-schema/init.js | 22 ++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js index 7b0fdb096ebc..4ee653480bf5 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/browser'; import { createClient } from '@supabase/supabase-js'; window.Sentry = Sentry; -const queues = createClient('https://test.supabase.co', 'test-key', { +const supabaseClient = createClient('https://test.supabase.co', 'test-key', { db: { schema: 'pgmq_public', }, @@ -11,21 +11,21 @@ const queues = createClient('https://test.supabase.co', 'test-key', { Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration(queues)], + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], tracesSampleRate: 1.0, }); // Simulate queue operations async function performQueueOperations() { try { - await queues.rpc('enqueue', { - queue_name: 'todos', - msg: { title: 'Test Todo' }, - }); + await supabaseClient.rpc('enqueue', { + queue_name: 'todos', + msg: { title: 'Test Todo' }, + }); - await queues.rpc('dequeue', { - queue_name: 'todos', - }); + await supabaseClient.rpc('dequeue', { + queue_name: 'todos', + }); } catch (error) { Sentry.captureException(error); } diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js index 43c50357f1eb..fa2c38cb4f43 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/browser'; import { createClient } from '@supabase/supabase-js'; window.Sentry = Sentry; -const queues = createClient('https://test.supabase.co', 'test-key', { +const supabaseClient = createClient('https://test.supabase.co', 'test-key', { db: { schema: 'pgmq_public', }, @@ -11,25 +11,21 @@ const queues = createClient('https://test.supabase.co', 'test-key', { Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration(queues)], + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], tracesSampleRate: 1.0, }); // Simulate queue operations async function performQueueOperations() { try { - await queues - .schema('pgmq_public') - .rpc('enqueue', { - queue_name: 'todos', - msg: { title: 'Test Todo' }, - }); + await supabaseClient.schema('pgmq_public').rpc('enqueue', { + queue_name: 'todos', + msg: { title: 'Test Todo' }, + }); - await queues - .schema('pgmq_public') - .rpc('dequeue', { - queue_name: 'todos', - }); + await supabaseClient.schema('pgmq_public').rpc('dequeue', { + queue_name: 'todos', + }); } catch (error) { Sentry.captureException(error); } From 8d38e7c39918981c908ea3ee729071b50289a003 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 13 May 2025 12:45:54 +0100 Subject: [PATCH 04/17] Lint --- .../suites/integrations/supabase/queues-rpc/init.js | 2 +- .../suites/integrations/supabase/queues-rpc/test.ts | 3 +-- .../suites/integrations/supabase/queues-schema/init.js | 2 +- .../suites/integrations/supabase/queues-schema/test.ts | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js index 4ee653480bf5..45c335254887 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/browser'; - import { createClient } from '@supabase/supabase-js'; + window.Sentry = Sentry; const supabaseClient = createClient('https://test.supabase.co', 'test-key', { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts index 2bd3f9bd4b1e..0f11708bbedd 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts @@ -1,7 +1,6 @@ -import type { Page} from '@playwright/test'; +import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import type { Event } from '@sentry/core'; - import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js index fa2c38cb4f43..fbdbd38a4ccc 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/browser'; - import { createClient } from '@supabase/supabase-js'; + window.Sentry = Sentry; const supabaseClient = createClient('https://test.supabase.co', 'test-key', { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts index c08022acaa47..e7ad4154f87b 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts @@ -1,6 +1,5 @@ import { type Page, expect } from '@playwright/test'; import type { Event } from '@sentry/core'; - import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; From a5a271e4e6980e94a263ddfcdf3d5819b88d625b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 27 May 2025 14:26:33 +0100 Subject: [PATCH 05/17] Update implementation --- .../supabase-nextjs/package.json | 4 +- .../pages/api/batch_enqueue.ts | 37 ++ .../pages/api/dequeue-error.ts | 27 ++ .../supabase-nextjs/pages/api/dequeue-rpc.ts | 31 ++ .../pages/api/dequeue-schema.ts | 25 ++ .../supabase-nextjs/pages/api/enqueue-rpc.ts | 32 ++ .../pages/api/enqueue-schema.ts | 28 ++ .../supabase-nextjs/pages/api/queue_read.ts | 31 ++ .../supabase-nextjs/supabase/config.toml | 9 +- .../migrations/20230712094349_init.sql | 2 +- .../20250515080602_enable-queues.sql | 182 ++++++++++ .../supabase-nextjs/supabase/seed.sql | 2 - .../supabase-nextjs/tests/performance.test.ts | 335 +++++++++++++++++- packages/core/src/integrations/supabase.ts | 225 ++++++++---- 14 files changed, 898 insertions(+), 72 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 05c8f96b2cae..0fe40b8d57bc 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "clean": "npx rimraf node_modules pnpm-lock.yaml .next", - "start-local-supabase": "supabase init --force --workdir . && supabase start -o env && supabase db reset", + "start-local-supabase": "supabase start -o env && supabase db reset", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", "test:assert": "pnpm test:prod" @@ -25,7 +25,7 @@ "next": "14.2.25", "react": "18.2.0", "react-dom": "18.2.0", - "supabase": "2.19.7", + "supabase": "2.22.12", "typescript": "4.9.5" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts new file mode 100644 index 000000000000..14208a00f450 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts @@ -0,0 +1,37 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Enqueue a job to the queue + const { data, error } = await supabaseClient.rpc('send_batch', { + queue_name: 'todos', + messages: [ + { + title: 'Test Todo 1', + }, + { + title: 'Test Todo 2', + }, + ], + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts new file mode 100644 index 000000000000..d6543c0d2ede --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts @@ -0,0 +1,27 @@ +// Enqueue a job to the queue + +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Enqueue a job to the queue + const { data, error } = await supabaseClient.schema('pgmq_public').rpc('pop', { + queue_name: 'non-existing-queue', + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts new file mode 100644 index 000000000000..e1c7caa0c6d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts @@ -0,0 +1,31 @@ +// Enqueue a job to the queue + +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Enqueue a job to the queue + const { data, error } = await supabaseClient.rpc('pop', { + queue_name: 'todos', + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts new file mode 100644 index 000000000000..ec77e7258e1e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts @@ -0,0 +1,25 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Process a job from the queue + const { data, error } = await supabaseClient.schema('pgmq_public').rpc('pop', { + queue_name: 'todos', + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts new file mode 100644 index 000000000000..a4d161fc224e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts @@ -0,0 +1,32 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Enqueue a job to the queue + const { data, error } = await supabaseClient.rpc('send', { + queue_name: 'todos', + message: { + title: 'Test Todo', + }, + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts new file mode 100644 index 000000000000..92f81f27d49e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Enqueue a job to the queue + const { data, error } = await supabaseClient.schema('pgmq_public').rpc('send', { + queue_name: 'todos', + message: { + title: 'Test Todo', + }, + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts new file mode 100644 index 000000000000..8fbc98584128 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Read from queue + const { data, error } = await supabaseClient.rpc('read', { + queue_name: 'todos', + n: 2, + sleep_seconds: 0, + }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml index 35dcff35bec4..6d003c8a64fd 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml @@ -10,9 +10,9 @@ enabled = true port = 54321 # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API # endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] +schemas = ["public", "graphql_public", "storage", "pgmq_public"] # Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] +extra_search_path = ["public", "extensions", "pgmq_public"] # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size # for accidental or malicious requests. max_rows = 1000 @@ -28,7 +28,7 @@ port = 54322 shadow_port = 54320 # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. -major_version = 15 +major_version = 17 [db.pooler] enabled = false @@ -141,7 +141,6 @@ sign_in_sign_ups = 30 # Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. token_verifications = 30 - # Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. # [auth.captcha] # enabled = true @@ -283,6 +282,8 @@ enabled = true policy = "oneshot" # Port to attach the Chrome inspector for debugging edge functions. inspector_port = 8083 +# The Deno major version to use. +deno_version = 1 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)" diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql index 1b1a98ace2e4..2af0497506c6 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql @@ -13,4 +13,4 @@ create policy "Individuals can view their own todos. " on todos for create policy "Individuals can update their own todos." on todos for update using (auth.uid() = user_id); create policy "Individuals can delete their own todos." on todos for - delete using (auth.uid() = user_id); \ No newline at end of file + delete using (auth.uid() = user_id); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql new file mode 100644 index 000000000000..8eba5c8de3a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql @@ -0,0 +1,182 @@ + +-- Enable queues +create extension if not exists "pgmq"; +select pgmq.create('todos'); +alter table "pgmq"."q_todos" enable row level security; + +--- The following code is vendored in from the supabase implementation for now +--- By default, the pgmq schema is not exposed to the public +--- And there is no other way to enable access locally without using the UI +--- Vendored from: https://github.com/supabase/supabase/blob/aa9070c9087ce8c37a27e7c74ea0353858aed6c2/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts#L18-L191 +create schema if not exists pgmq_public; +grant usage on schema pgmq_public to postgres, anon, authenticated, service_role; + +create or replace function pgmq_public.pop( + queue_name text +) + returns setof pgmq.message_record + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.pop( + queue_name := queue_name + ); +end; +$$; + +comment on function pgmq_public.pop(queue_name text) is 'Retrieves and locks the next message from the specified queue.'; + + +create or replace function pgmq_public.send( + queue_name text, + message jsonb, + sleep_seconds integer default 0 -- renamed from 'delay' +) + returns setof bigint + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.send( + queue_name := queue_name, + msg := message, + delay := sleep_seconds + ); +end; +$$; + +comment on function pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer) is 'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.'; + + +create or replace function pgmq_public.send_batch( + queue_name text, + messages jsonb[], + sleep_seconds integer default 0 -- renamed from 'delay' +) + returns setof bigint + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.send_batch( + queue_name := queue_name, + msgs := messages, + delay := sleep_seconds + ); +end; +$$; + +comment on function pgmq_public.send_batch(queue_name text, messages jsonb[], sleep_seconds integer) is 'Sends a batch of messages to the specified queue, optionally delaying their availability by a number of seconds.'; + + +create or replace function pgmq_public.archive( + queue_name text, + message_id bigint +) + returns boolean + language plpgsql + set search_path = '' +as $$ +begin + return + pgmq.archive( + queue_name := queue_name, + msg_id := message_id + ); +end; +$$; + +comment on function pgmq_public.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; + + +create or replace function pgmq_public.delete( + queue_name text, + message_id bigint +) + returns boolean + language plpgsql + set search_path = '' +as $$ +begin + return + pgmq.delete( + queue_name := queue_name, + msg_id := message_id + ); +end; +$$; + +comment on function pgmq_public.delete(queue_name text, message_id bigint) is 'Permanently deletes a message from the specified queue.'; + +create or replace function pgmq_public.read( + queue_name text, + sleep_seconds integer, + n integer +) + returns setof pgmq.message_record + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.read( + queue_name := queue_name, + vt := sleep_seconds, + qty := n + ); +end; +$$; + +comment on function pgmq_public.read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; + +-- Grant execute permissions on wrapper functions to roles +grant execute on function pgmq_public.pop(text) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.archive(text, bigint) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.delete(text, bigint) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.read(text, integer, integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.read(text, integer, integer) to postgres, service_role, anon, authenticated; + +-- For the service role, we want full access +-- Grant permissions on existing tables +grant all privileges on all tables in schema pgmq to postgres, service_role; + +-- Ensure service_role has permissions on future tables +alter default privileges in schema pgmq grant all privileges on tables to postgres, service_role; + +grant usage on schema pgmq to postgres, anon, authenticated, service_role; + + +/* + Grant access to sequences to API roles by default. Existing table permissions + continue to enforce insert restrictions. This is necessary to accommodate the + on-backup hook that rebuild queue table primary keys to avoid a pg_dump segfault. + This can be removed once logical backups are completely retired. +*/ +grant usage, select, update +on all sequences in schema pgmq +to anon, authenticated, service_role; + +alter default privileges in schema pgmq +grant usage, select, update +on sequences +to anon, authenticated, service_role; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql index 57b5c4d07e05..e69de29bb2d1 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql @@ -1,2 +0,0 @@ -TRUNCATE auth.users CASCADE; -TRUNCATE auth.identities CASCADE; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index cfb66b372420..c8485cff264a 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; // This test should be run in serial mode to ensure that the test user is created before the other tests test.describe.configure({ mode: 'serial' }); @@ -210,3 +210,336 @@ test('Sends server-side Supabase auth admin `listUsers` span', async ({ page, ba origin: 'auto.db.supabase', }); }); + +test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/enqueue-schema' + ); + }); + + const result = await fetch(`${baseURL}/api/enqueue-schema`); + + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ data: [1] }); + + const transactionEvent = await httpTransactionPromise; + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '1', + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.publish', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.rpc.send', + message: 'rpc(send)', + data: { + 'messaging.destination.name': 'todos', + 'messaging.message.id': '1', + }, + }); +}); + +test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/enqueue-rpc' + ); + }); + + const result = await fetch(`${baseURL}/api/enqueue-rpc`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ data: [2] }); + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '2', + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.publish', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.rpc.send', + message: 'rpc(send)', + data: { + 'messaging.destination.name': 'todos', + 'messaging.message.id': '2', + }, + }); +}); + +test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/dequeue-schema' + ); + }); + + const result = await fetch(`${baseURL}/api/dequeue-schema`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + expect(await result.json()).toEqual( + expect.objectContaining({ data: [expect.objectContaining({ message: { title: 'Test Todo' }, msg_id: 1 })] }), + ); + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '1', + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.process', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.rpc.pop', + message: 'rpc(pop)', + data: { + 'messaging.destination.name': 'todos', + 'messaging.message.id': '1', + }, + }); +}); + +test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/dequeue-rpc' + ); + }); + + const result = await fetch(`${baseURL}/api/dequeue-rpc`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + expect(await result.json()).toEqual( + expect.objectContaining({ data: [expect.objectContaining({ message: { title: 'Test Todo' }, msg_id: 2 })] }), + ); + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '2', + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.process', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.rpc.pop', + message: 'rpc(pop)', + data: { + 'messaging.destination.name': 'todos', + 'messaging.message.id': '2', + }, + }); +}); + +test('Sends queue process error spans with `rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/dequeue-error' + ); + }); + + const errorEventPromise = waitForError('supabase-nextjs', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value?.includes('pgmq.q_non-existing-queue'); + }); + + const result = await fetch(`${baseURL}/api/dequeue-error`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(500); + expect(await result.json()).toEqual( + expect.objectContaining({ + error: expect.stringContaining('relation "pgmq.q_non-existing-queue" does not exist'), + }), + ); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + + expect(errorEvent.exception?.values?.[0].value).toBe('relation "pgmq.q_non-existing-queue" does not exist'); + expect(errorEvent.contexts?.supabase).toEqual({ + queueName: 'non-existing-queue', + }); + + expect(errorEvent.breadcrumbs).toContainEqual( + expect.objectContaining({ + type: 'supabase', + category: 'db.rpc.pop', + message: 'rpc(pop)', + data: { + 'messaging.destination.name': 'non-existing-queue', + }, + }), + ); + + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'non-existing-queue', + 'messaging.system': 'supabase', + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.process', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'unknown_error', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/batch_enqueue' + ); + }); + + const result = await fetch(`${baseURL}/api/batch_enqueue`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ data: [3, 4] }); + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '3,4', + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.publish', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.rpc.send_batch', + message: 'rpc(send_batch)', + data: { + 'messaging.destination.name': 'todos', + 'messaging.message.id': '3,4', + }, + }); +}); + +test('Sends `read` queue operation spans with `rpc(...)`', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /api/queue_read' + ); + }); + const result = await fetch(`${baseURL}/api/queue_read`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + expect(await result.json()).toEqual( + expect.objectContaining({ data: [ + expect.objectContaining({ message: { title: 'Test Todo 1' }, msg_id: 3 }), + expect.objectContaining({ message: { title: 'Test Todo 2' }, msg_id: 4 }), + ] }), + ); + + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'messaging.destination.name': 'todos', + 'messaging.system': 'supabase', + 'messaging.message.id': '3,4', + 'sentry.op': 'queue.receive', + 'sentry.origin': 'auto.db.supabase', + }, + description: 'supabase.db.rpc', + op: 'queue.receive', + origin: 'auto.db.supabase', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 6ab7f21c31b0..02a5d093a265 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -81,6 +81,7 @@ type AuthAdminOperationName = (typeof AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT)[numbe type PostgRESTQueryOperationFn = (...args: unknown[]) => PostgRESTFilterBuilder; export interface SupabaseClientInstance { + rpc: (fn: string, params: Record) => Promise; auth: { admin: Record; } & Record; @@ -100,6 +101,12 @@ export interface PostgRESTFilterBuilder { export interface SupabaseResponse { status?: number; + data?: Array< + | number + | { + msg_id?: number; + } + >; error?: { message: string; code?: string; @@ -218,73 +225,160 @@ export function translateFiltersIntoMethods(key: string, query: string): string } function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { + if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema)) { + return; + } + (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema = new Proxy( (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema, { apply(target, thisArg, argumentsList) { - const rv = Reflect.apply(target, thisArg, argumentsList); + const supabaseInstance = Reflect.apply(target, thisArg, argumentsList); - return instrumentRpc(rv); + (supabaseInstance as unknown as SupabaseClientConstructor).rpc = new Proxy( + (supabaseInstance as unknown as SupabaseClientInstance).rpc, + { + apply(target, thisArg, argumentsList) { + return instrumentRpcImpl(target, thisArg, argumentsList); + }, + }, + ); + + return supabaseInstance; }, }, ); + + markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema); } -function instrumentRpc(SupabaseClient: unknown): unknown { - (SupabaseClient as unknown as SupabaseClientConstructor).rpc = new Proxy( - (SupabaseClient as unknown as SupabaseClientConstructor).rpc, +const instrumentRpcImpl = (target: any, thisArg: any, argumentsList: any[]): Promise => { + const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; + const isConsumerSpan = argumentsList[0] === 'pop'; + const isReceiverSpan = argumentsList[0] === 'read'; + + if (!isProducerSpan && !isConsumerSpan && !isReceiverSpan) { + return Reflect.apply(target, thisArg, argumentsList); + } + + const maybeQueueParams = argumentsList[1]; + + // If the second argument is not an object, it's not a queue operation + if (!isPlainObject(maybeQueueParams)) { + return Reflect.apply(target, thisArg, argumentsList); + } + + const queueName = maybeQueueParams?.queue_name as string; + + const op = isProducerSpan + ? 'queue.publish' + : isConsumerSpan + ? 'queue.process' + : isReceiverSpan + ? 'queue.receive' + : ''; + + // If the operation is not a queue operation, return the original function + if (!op) { + return Reflect.apply(target, thisArg, argumentsList); + } + + return startSpan( { - apply(target, thisArg, argumentsList) { - const isProducerSpan = argumentsList[0] === 'enqueue'; - const isConsumerSpan = argumentsList[0] === 'dequeue'; + name: 'supabase.db.rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + 'messaging.system': 'supabase', + }, + }, + async span => { + try { + return (Reflect.apply(target, thisArg, argumentsList) as Promise).then( + (res: SupabaseResponse) => { + const messageId = + res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; - const maybeQueueParams = argumentsList[1]; + if (messageId) { + span.setAttribute('messaging.message.id', messageId); + } - // If the second argument is not an object, it's not a queue operation - if (!isPlainObject(maybeQueueParams)) { - return Reflect.apply(target, thisArg, argumentsList); - } + if (queueName) { + span.setAttribute('messaging.destination.name', queueName); + } - const msg = maybeQueueParams?.msg as { title: string }; + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.rpc.${argumentsList[0]}`, + message: `rpc(${argumentsList[0]})`, + }; - const messageId = msg?.title; - const queueName = maybeQueueParams?.queue_name as string; + const data: Record = {}; - const op = isProducerSpan ? 'queue.publish' : isConsumerSpan ? 'queue.process' : ''; + if (messageId) { + data['messaging.message.id'] = messageId; + } - // If the operation is not a queue operation, return the original function - if (!op) { - return Reflect.apply(target, thisArg, argumentsList); - } + if (queueName) { + data['messaging.destination.name'] = queueName; + } - return startSpan( - { - name: 'supabase.db.rpc', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - }, - }, - async span => { - return (Reflect.apply(target, thisArg, argumentsList) as Promise).then((res: unknown) => { - if (messageId) { - span.setAttribute('messaging.message.id', messageId); + if (Object.keys(data).length) { + breadcrumb.data = data; + } + + addBreadcrumb(breadcrumb); + + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + + if (res.error.code) { + err.code = res.error.code; } - if (queueName) { - span.setAttribute('messaging.destination.name', queueName); + if (res.error.details) { + err.details = res.error.details; } - span.end(); - return res; - }); + captureException(err, { + contexts: { + supabase: { + queueName, + messageId, + }, + }, + }); + + span.setStatus({ code: SPAN_STATUS_ERROR }); + } + + span.end(); + + return res; }, ); - }, + } catch (err: unknown) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + span.end(); + captureException(err, { + mechanism: { + handled: false, + }, + }); + } }, ); +}; - return SupabaseClient; +function instrumentRpc(SupabaseClient: unknown): void { + (SupabaseClient as unknown as SupabaseClientInstance).rpc = new Proxy( + (SupabaseClient as unknown as SupabaseClientInstance).rpc, + { + apply(target, thisArg, argumentsList) { + return instrumentRpcImpl(target, thisArg, argumentsList); + }, + }, + ); } function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn { @@ -417,6 +511,13 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } const pathParts = typedThis.url.pathname.split('/'); + + if (pathParts.includes('rpc')) { + // RPC calls are instrumented in the `instrumentRpc` function + // and should not be instrumented here. + return Reflect.apply(target, thisArg, argumentsList); + } + const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : ''; const queryItems: string[] = []; @@ -474,6 +575,28 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte span.end(); } + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.${operation}`, + message: description, + }; + + const data: Record = {}; + + if (queryItems.length) { + data.query = queryItems; + } + + if (Object.keys(body).length) { + data.body = body; + } + + if (Object.keys(data).length) { + breadcrumb.data = data; + } + + addBreadcrumb(breadcrumb); + if (res.error) { const err = new Error(res.error.message) as SupabaseError; if (res.error.code) { @@ -507,28 +630,6 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte }); } - const breadcrumb: SupabaseBreadcrumb = { - type: 'supabase', - category: `db.${operation}`, - message: description, - }; - - const data: Record = {}; - - if (queryItems.length) { - data.query = queryItems; - } - - if (Object.keys(body).length) { - data.body = body; - } - - if (Object.keys(data).length) { - breadcrumb.data = data; - } - - addBreadcrumb(breadcrumb); - return res; }, (err: Error) => { From 6e0bc2442192dc70a209b232f9a390ad8e8f464d Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 27 May 2025 15:21:19 +0100 Subject: [PATCH 06/17] Update playwright tests --- .../integrations/supabase/queues-rpc/init.js | 4 +-- .../integrations/supabase/queues-rpc/test.ts | 29 +++++++++++++----- .../supabase/queues-schema/init.js | 4 +-- .../supabase/queues-schema/test.ts | 30 ++++++++++++++----- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js index 45c335254887..15309015bbd9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js @@ -18,12 +18,12 @@ Sentry.init({ // Simulate queue operations async function performQueueOperations() { try { - await supabaseClient.rpc('enqueue', { + await supabaseClient.rpc('send', { queue_name: 'todos', msg: { title: 'Test Todo' }, }); - await supabaseClient.rpc('dequeue', { + await supabaseClient.rpc('pop', { queue_name: 'todos', }); } catch (error) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts index 0f11708bbedd..d0f1534a404a 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts @@ -5,12 +5,24 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; async function mockSupabaseRoute(page: Page) { - await page.route('**/rest/v1/rpc**', route => { + await page.route('**/rpc/**/send', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - foo: ['bar', 'baz'], - }), + body: JSON.stringify([0]), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + await page.route('**/rpc/**/pop', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify([ + { + msg_id: 0, + }, + ]), headers: { 'Content-Type': 'application/json', }, @@ -25,16 +37,16 @@ if (bundle.startsWith('bundle')) { } sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLocalTestUrl, page }) => { - await mockSupabaseRoute(page); - if (shouldSkipTracingTest()) { return; } + await mockSupabaseRoute(page); + const url = await getLocalTestUrl({ testDir: __dirname }); const event = await getFirstSentryEnvelopeRequest(page, url); - const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue')); + const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue.')); expect(queueSpans).toHaveLength(2); @@ -49,7 +61,7 @@ sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLo 'sentry.op': 'queue.publish', 'sentry.origin': 'auto.db.supabase', 'messaging.destination.name': 'todos', - 'messaging.message.id': 'Test Todo', + 'messaging.message.id': '0', }), }); @@ -64,6 +76,7 @@ sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLo 'sentry.op': 'queue.process', 'sentry.origin': 'auto.db.supabase', 'messaging.destination.name': 'todos', + 'messaging.message.id': '0', }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js index fbdbd38a4ccc..0cbc629a2b3e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js @@ -18,12 +18,12 @@ Sentry.init({ // Simulate queue operations async function performQueueOperations() { try { - await supabaseClient.schema('pgmq_public').rpc('enqueue', { + await supabaseClient.schema('pgmq_public').rpc('send', { queue_name: 'todos', msg: { title: 'Test Todo' }, }); - await supabaseClient.schema('pgmq_public').rpc('dequeue', { + await supabaseClient.schema('pgmq_public').rpc('pop', { queue_name: 'todos', }); } catch (error) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts index e7ad4154f87b..6417f7796964 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts @@ -4,12 +4,24 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; async function mockSupabaseRoute(page: Page) { - await page.route('**/rest/v1/rpc**', route => { + await page.route('**/rpc/**/send', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - foo: ['bar', 'baz'], - }), + body: JSON.stringify([0]), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + await page.route('**/rpc/**/pop', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify([ + { + msg_id: 0, + }, + ]), headers: { 'Content-Type': 'application/json', }, @@ -24,16 +36,17 @@ if (bundle.startsWith('bundle')) { } sentryTest('should capture Supabase queue spans from client.schema(...).rpc', async ({ getLocalTestUrl, page }) => { - await mockSupabaseRoute(page); - if (shouldSkipTracingTest()) { return; } + await mockSupabaseRoute(page); + const url = await getLocalTestUrl({ testDir: __dirname }); const event = await getFirstSentryEnvelopeRequest(page, url); - const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue')); + + const queueSpans = event.spans?.filter(({ op }) => op?.startsWith('queue.')); expect(queueSpans).toHaveLength(2); @@ -48,7 +61,7 @@ sentryTest('should capture Supabase queue spans from client.schema(...).rpc', as 'sentry.op': 'queue.publish', 'sentry.origin': 'auto.db.supabase', 'messaging.destination.name': 'todos', - 'messaging.message.id': 'Test Todo', + 'messaging.message.id': '0', }), }); @@ -63,6 +76,7 @@ sentryTest('should capture Supabase queue spans from client.schema(...).rpc', as 'sentry.op': 'queue.process', 'sentry.origin': 'auto.db.supabase', 'messaging.destination.name': 'todos', + 'messaging.message.id': '0', }), }); }); From 5327a3cc65809dc030a5e2a8778dedfef73f40ea Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 28 May 2025 12:25:03 +0100 Subject: [PATCH 07/17] Tidy up test endpoints --- .../consumer-error.ts} | 0 .../{dequeue-rpc.ts => queue/consumer-rpc.ts} | 0 .../consumer-schema.ts} | 0 .../producer-batch.ts} | 0 .../{enqueue-rpc.ts => queue/producer-rpc.ts} | 0 .../producer-schema.ts} | 0 .../{queue_read.ts => queue/receiver-rpc.ts} | 0 .../supabase-nextjs/tests/performance.test.ts | 28 +++++++++---------- 8 files changed, 14 insertions(+), 14 deletions(-) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{dequeue-error.ts => queue/consumer-error.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{dequeue-rpc.ts => queue/consumer-rpc.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{dequeue-schema.ts => queue/consumer-schema.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{batch_enqueue.ts => queue/producer-batch.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{enqueue-rpc.ts => queue/producer-rpc.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{enqueue-schema.ts => queue/producer-schema.ts} (100%) rename dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/{queue_read.ts => queue/receiver-rpc.ts} (100%) diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-error.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-error.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-error.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-rpc.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-rpc.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-rpc.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-schema.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/dequeue-schema.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/consumer-schema.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-batch.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/batch_enqueue.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-batch.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-rpc.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-rpc.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-rpc.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-schema.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/enqueue-schema.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-schema.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue_read.ts rename to dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index c8485cff264a..f3b46ad0f4e4 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -215,11 +215,11 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/enqueue-schema' + transactionEvent?.transaction === 'GET /api/queue/producer-schema' ); }); - const result = await fetch(`${baseURL}/api/enqueue-schema`); + const result = await fetch(`${baseURL}/api/queue/producer-schema`); expect(result.status).toBe(200); expect(await result.json()).toEqual({ data: [1] }); @@ -262,11 +262,11 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/enqueue-rpc' + transactionEvent?.transaction === 'GET /api/queue/producer-rpc' ); }); - const result = await fetch(`${baseURL}/api/enqueue-rpc`); + const result = await fetch(`${baseURL}/api/queue/producer-rpc`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); @@ -308,11 +308,11 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/dequeue-schema' + transactionEvent?.transaction === 'GET /api/queue/consumer-schema' ); }); - const result = await fetch(`${baseURL}/api/dequeue-schema`); + const result = await fetch(`${baseURL}/api/queue/consumer-schema`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); @@ -356,11 +356,11 @@ test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/dequeue-rpc' + transactionEvent?.transaction === 'GET /api/queue/consumer-rpc' ); }); - const result = await fetch(`${baseURL}/api/dequeue-rpc`); + const result = await fetch(`${baseURL}/api/queue/consumer-rpc`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); @@ -404,7 +404,7 @@ test('Sends queue process error spans with `rpc(...)`', async ({ page, baseURL } const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/dequeue-error' + transactionEvent?.transaction === 'GET /api/queue/consumer-error' ); }); @@ -412,7 +412,7 @@ test('Sends queue process error spans with `rpc(...)`', async ({ page, baseURL } return errorEvent?.exception?.values?.[0]?.value?.includes('pgmq.q_non-existing-queue'); }); - const result = await fetch(`${baseURL}/api/dequeue-error`); + const result = await fetch(`${baseURL}/api/queue/consumer-error`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(500); @@ -464,11 +464,11 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/batch_enqueue' + transactionEvent?.transaction === 'GET /api/queue/producer-batch' ); }); - const result = await fetch(`${baseURL}/api/batch_enqueue`); + const result = await fetch(`${baseURL}/api/queue/producer-batch`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); @@ -509,10 +509,10 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } test('Sends `read` queue operation spans with `rpc(...)`', async ({ page, baseURL }) => { const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /api/queue_read' + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /api/queue/receiver-rpc' ); }); - const result = await fetch(`${baseURL}/api/queue_read`); + const result = await fetch(`${baseURL}/api/queue/receiver-rpc`); const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); From a5744f0d85efe9c4827bf811a797ba950591ea6c Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Sat, 31 May 2025 21:08:24 +0100 Subject: [PATCH 08/17] Refactor / reimplement --- .../supabase-nextjs/package.json | 2 +- .../pages/api/queue/receiver-rpc.ts | 31 --- .../supabase-nextjs/tests/performance.test.ts | 107 +++----- packages/core/src/integrations/supabase.ts | 257 ++++++++++++++---- 4 files changed, 246 insertions(+), 151 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 0fe40b8d57bc..775e5ad0bf1e 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -25,7 +25,7 @@ "next": "14.2.25", "react": "18.2.0", "react-dom": "18.2.0", - "supabase": "2.22.12", + "supabase": "2.23.4", "typescript": "4.9.5" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts deleted file mode 100644 index 8fbc98584128..000000000000 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/receiver-rpc.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { createClient } from '@supabase/supabase-js'; -import * as Sentry from '@sentry/nextjs'; - -// These are the default development keys for a local Supabase instance -const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; -const SUPABASE_SERVICE_ROLE_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; - -const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { - db: { - schema: 'pgmq_public', - }, -}); - -Sentry.instrumentSupabaseClient(supabaseClient); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // Read from queue - const { data, error } = await supabaseClient.rpc('read', { - queue_name: 'todos', - n: 2, - sleep_seconds: 0, - }); - - if (error) { - return res.status(500).json({ error: error.message }); - } - - return res.status(200).json({ data }); -} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index f3b46ad0f4e4..c91e9f50bb98 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -305,42 +305,48 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { }); test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, baseURL }) => { - const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + const consumerSpanPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/queue/consumer-schema' + transactionEvent?.contexts?.trace?.op === 'queue.process' && transactionEvent?.transaction === 'supabase.db.rpc' ); }); const result = await fetch(`${baseURL}/api/queue/consumer-schema`); - const transactionEvent = await httpTransactionPromise; + const consumerEvent = await consumerSpanPromise; expect(result.status).toBe(200); expect(await result.json()).toEqual( - expect.objectContaining({ data: [expect.objectContaining({ message: { title: 'Test Todo' }, msg_id: 1 })] }), + expect.objectContaining({ + data: [ + expect.objectContaining({ + message: { + title: 'Test Todo', + }, + msg_id: expect.any(Number), + }), + ], + }), ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toContainEqual({ + expect(consumerEvent.contexts.trace).toEqual({ data: { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '1', + 'messaging.message.receive.latency': expect.any(Number), 'sentry.op': 'queue.process', 'sentry.origin': 'auto.db.supabase', + 'sentry.source': 'route', }, - description: 'supabase.db.rpc', op: 'queue.process', origin: 'auto.db.supabase', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), + parent_span_id: expect.any(String), + span_id: expect.any(String), status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.any(String), }); - expect(transactionEvent.breadcrumbs).toContainEqual({ + expect(consumerEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.rpc.pop', @@ -353,42 +359,47 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas }); test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { - const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + const consumerSpanPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/queue/consumer-rpc' + transactionEvent?.contexts?.trace?.op === 'queue.process' && transactionEvent?.transaction === 'supabase.db.rpc' ); }); const result = await fetch(`${baseURL}/api/queue/consumer-rpc`); - const transactionEvent = await httpTransactionPromise; + const consumerEvent = await consumerSpanPromise; expect(result.status).toBe(200); expect(await result.json()).toEqual( - expect.objectContaining({ data: [expect.objectContaining({ message: { title: 'Test Todo' }, msg_id: 2 })] }), + expect.objectContaining({ + data: [ + expect.objectContaining({ + message: { + title: 'Test Todo', + }, + msg_id: expect.any(Number), + }), + ], + }), ); - - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toContainEqual({ + expect(consumerEvent.contexts.trace).toEqual({ data: { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '2', + 'messaging.message.receive.latency': expect.any(Number), 'sentry.op': 'queue.process', 'sentry.origin': 'auto.db.supabase', + 'sentry.source': 'route', }, - description: 'supabase.db.rpc', op: 'queue.process', origin: 'auto.db.supabase', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), + parent_span_id: expect.any(String), + span_id: expect.any(String), status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.any(String), }); - expect(transactionEvent.breadcrumbs).toContainEqual({ + expect(consumerEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.rpc.pop', @@ -505,41 +516,3 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } }, }); }); - -test('Sends `read` queue operation spans with `rpc(...)`', async ({ page, baseURL }) => { - const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /api/queue/receiver-rpc' - ); - }); - const result = await fetch(`${baseURL}/api/queue/receiver-rpc`); - const transactionEvent = await httpTransactionPromise; - - expect(result.status).toBe(200); - expect(await result.json()).toEqual( - expect.objectContaining({ data: [ - expect.objectContaining({ message: { title: 'Test Todo 1' }, msg_id: 3 }), - expect.objectContaining({ message: { title: 'Test Todo 2' }, msg_id: 4 }), - ] }), - ); - - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'messaging.destination.name': 'todos', - 'messaging.system': 'supabase', - 'messaging.message.id': '3,4', - 'sentry.op': 'queue.receive', - 'sentry.origin': 'auto.db.supabase', - }, - description: 'supabase.db.rpc', - op: 'queue.receive', - origin: 'auto.db.supabase', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); -}); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 02a5d093a265..f420801d0b48 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -7,7 +7,7 @@ import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; -import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; +import { continueTrace, setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; import type { IntegrationFn } from '../types-hoist/integration'; import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; @@ -101,12 +101,16 @@ export interface PostgRESTFilterBuilder { export interface SupabaseResponse { status?: number; - data?: Array< - | number - | { - msg_id?: number; - } - >; + data?: Array<{ + msg_id?: number; + enqueued_at?: string; + message?: { + _sentry?: { + sentry_trace?: string; + baggage?: string; + }; + }; + }>; error?: { message: string; code?: string; @@ -239,7 +243,21 @@ function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { (supabaseInstance as unknown as SupabaseClientInstance).rpc, { apply(target, thisArg, argumentsList) { - return instrumentRpcImpl(target, thisArg, argumentsList); + const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; + const isConsumerSpan = argumentsList[0] === 'pop'; + + if (!isProducerSpan && !isConsumerSpan) { + return Reflect.apply(target, thisArg, argumentsList); + } + + if (isProducerSpan) { + return instrumentRpcProducer(target, thisArg, argumentsList); + } else if (isConsumerSpan) { + return instrumentRpcConsumer(target, thisArg, argumentsList); + } + + // If the operation is not a queue operation, return the original function + return Reflect.apply(target, thisArg, argumentsList); }, }, ); @@ -252,50 +270,58 @@ function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema); } -const instrumentRpcImpl = (target: any, thisArg: any, argumentsList: any[]): Promise => { - const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; - const isConsumerSpan = argumentsList[0] === 'pop'; - const isReceiverSpan = argumentsList[0] === 'read'; - - if (!isProducerSpan && !isConsumerSpan && !isReceiverSpan) { - return Reflect.apply(target, thisArg, argumentsList); +function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: string; baggage?: string } }): { + sentryTrace?: string; + baggage?: string; +} { + if (message?._sentry) { + return { + sentryTrace: message._sentry.sentry_trace, + baggage: message._sentry.baggage, + }; } + return {}; +} - const maybeQueueParams = argumentsList[1]; +const instrumentRpcConsumer = (target: any, thisArg: any, argumentsList: any[]): Promise => { + const [operationName, queueParams] = argumentsList as [ + 'pop', + { + queue_name?: string; + }, + ]; - // If the second argument is not an object, it's not a queue operation - if (!isPlainObject(maybeQueueParams)) { - return Reflect.apply(target, thisArg, argumentsList); + const isConsumerSpan = operationName === 'pop'; + const queueName = queueParams?.queue_name; + + if (!isConsumerSpan) { + return Reflect.apply(target, thisArg, argumentsList); // Not a consumer operation } - const queueName = maybeQueueParams?.queue_name as string; + return (Reflect.apply(target, thisArg, argumentsList) as Promise).then((res: SupabaseResponse) => { + const latency = res.data?.[0]?.enqueued_at ? Date.now() - Date.parse(res.data?.[0]?.enqueued_at) : undefined; - const op = isProducerSpan - ? 'queue.publish' - : isConsumerSpan - ? 'queue.process' - : isReceiverSpan - ? 'queue.receive' - : ''; + const { sentryTrace, baggage } = extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); - // If the operation is not a queue operation, return the original function - if (!op) { - return Reflect.apply(target, thisArg, argumentsList); - } + // Remove Sentry metadata from the returned message + delete res.data?.[0]?.message?._sentry; - return startSpan( - { - name: 'supabase.db.rpc', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - 'messaging.system': 'supabase', + return continueTrace( + { + sentryTrace, + baggage, }, - }, - async span => { - try { - return (Reflect.apply(target, thisArg, argumentsList) as Promise).then( - (res: SupabaseResponse) => { + () => { + return startSpan( + { + name: 'supabase.db.rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + 'messaging.system': 'supabase', + }, + }, + span => { const messageId = res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; @@ -307,6 +333,10 @@ const instrumentRpcImpl = (target: any, thisArg: any, argumentsList: any[]): Pro span.setAttribute('messaging.destination.name', queueName); } + if (latency) { + span.setAttribute('messaging.message.receive.latency', latency); + } + const breadcrumb: SupabaseBreadcrumb = { type: 'supabase', category: `db.rpc.${argumentsList[0]}`, @@ -350,6 +380,8 @@ const instrumentRpcImpl = (target: any, thisArg: any, argumentsList: any[]): Pro }); span.setStatus({ code: SPAN_STATUS_ERROR }); + } else { + span.setStatus({ code: SPAN_STATUS_OK }); } span.end(); @@ -357,25 +389,146 @@ const instrumentRpcImpl = (target: any, thisArg: any, argumentsList: any[]): Pro return res; }, ); - } catch (err: unknown) { - span.setStatus({ code: SPAN_STATUS_ERROR }); - span.end(); - captureException(err, { - mechanism: { - handled: false, - }, + }, + ); + }); +}; + +function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): Promise { + const maybeQueueParams = argumentsList[1]; + + // If the second argument is not an object, it's not a queue operation + if (!isPlainObject(maybeQueueParams)) { + return Reflect.apply(target, thisArg, argumentsList); + } + + const queueName = maybeQueueParams?.queue_name as string; + + // If the queue name is not provided, return the original function + if (!queueName) { + return Reflect.apply(target, thisArg, argumentsList); + } + + return startSpan( + { + name: 'supabase.db.rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish', + 'messaging.system': 'supabase', + }, + }, + span => { + const { 'sentry-trace': sentryTrace, baggage: sentryBaggage } = getTraceData(); + const [, sentryArgumentsQueueParams] = argumentsList as [ + 'send' | 'send_batch', + { + queue_name: string; + messages?: Array<{ _sentry?: { sentry_trace?: string; baggage?: string } }>; + message?: { _sentry?: { sentry_trace?: string; baggage?: string } }; + }, + ]; + + if (sentryArgumentsQueueParams?.message) { + sentryArgumentsQueueParams.message._sentry = { + sentry_trace: sentryTrace, + baggage: sentryBaggage, + }; + } else if (sentryArgumentsQueueParams?.messages) { + sentryArgumentsQueueParams.messages = sentryArgumentsQueueParams.messages.map(message => { + message._sentry = { + sentry_trace: sentryTrace, + baggage: sentryBaggage, + }; + return message; }); } + + argumentsList[1] = sentryArgumentsQueueParams; + + return (Reflect.apply(target, thisArg, argumentsList) as Promise) + .then((res: SupabaseResponse) => { + const messageId = + res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; + + if (messageId) { + span.setAttribute('messaging.message.id', messageId || ''); + } + + if (queueName) { + span.setAttribute('messaging.destination.name', queueName || ''); + } + + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.rpc.${argumentsList[0]}`, + message: `rpc(${argumentsList[0]})`, + }; + const data: Record = {}; + if (messageId) { + data['messaging.message.id'] = messageId; + } + if (queueName) { + data['messaging.destination.name'] = queueName; + } + if (Object.keys(data).length) { + breadcrumb.data = data; + } + addBreadcrumb(breadcrumb); + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + if (res.error.code) { + err.code = res.error.code; + } + if (res.error.details) { + err.details = res.error.details; + } + captureException(err, { + contexts: { + supabase: { + queueName, + messageId, + }, + }, + }); + span.setStatus({ code: SPAN_STATUS_ERROR }); + } else { + span.setStatus({ code: SPAN_STATUS_OK }); + } + span.end(); + + return res; + }) + .catch((err: unknown) => { + span.setStatus({ code: SPAN_STATUS_ERROR }); + span.end(); + captureException(err, { + mechanism: { + handled: false, + }, + }); + throw err; + }); }, ); -}; +} function instrumentRpc(SupabaseClient: unknown): void { (SupabaseClient as unknown as SupabaseClientInstance).rpc = new Proxy( (SupabaseClient as unknown as SupabaseClientInstance).rpc, { apply(target, thisArg, argumentsList) { - return instrumentRpcImpl(target, thisArg, argumentsList); + let result: Promise; + + if (argumentsList[0] === 'send' || argumentsList[0] === 'send_batch') { + result = instrumentRpcProducer(target, thisArg, argumentsList); + } else if (argumentsList[0] === 'pop') { + result = instrumentRpcConsumer(target, thisArg, argumentsList); + } else { + result = Reflect.apply(target, thisArg, argumentsList) as Promise; + } + + return result; }, }, ); From 2e6012a2688dc2452450705b50f7db1f64e34d6b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 20 Jun 2025 21:25:43 +0100 Subject: [PATCH 09/17] Add missing import --- packages/core/src/integrations/supabase.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index f420801d0b48..5cddee7374c8 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -12,6 +12,7 @@ import type { IntegrationFn } from '../types-hoist/integration'; import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; +import { getTraceData } from '../utils/traceData'; export interface SupabaseClientConstructor { prototype: { From b5ebc9a325e2baa95a629e23e62cb65b00e3d024 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 27 Jun 2025 14:08:20 +0100 Subject: [PATCH 10/17] Rename `SupabaseClientConstructor` to `SupabaseClientConstructorType` --- packages/core/src/integrations/supabase.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 5cddee7374c8..c93edc9c5fdd 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -14,7 +14,7 @@ import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; import { getTraceData } from '../utils/traceData'; -export interface SupabaseClientConstructor { +export interface SupabaseClientConstructorType { prototype: { from: (table: string) => PostgRESTQueryBuilder; schema: (schema: string) => { rpc: (...args: unknown[]) => Promise }; @@ -230,17 +230,17 @@ export function translateFiltersIntoMethods(key: string, query: string): string } function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { - if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema)) { + if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema)) { return; } - (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema = new Proxy( - (SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema, + (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema = new Proxy( + (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema, { apply(target, thisArg, argumentsList) { const supabaseInstance = Reflect.apply(target, thisArg, argumentsList); - (supabaseInstance as unknown as SupabaseClientConstructor).rpc = new Proxy( + (supabaseInstance as unknown as SupabaseClientConstructorType).rpc = new Proxy( (supabaseInstance as unknown as SupabaseClientInstance).rpc, { apply(target, thisArg, argumentsList) { @@ -268,7 +268,7 @@ function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { }, ); - markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.schema); + markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema); } function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: string; baggage?: string } }): { @@ -622,12 +622,12 @@ function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInst } function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void { - if (isInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from)) { + if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from)) { return; } - (SupabaseClient as SupabaseClientConstructor).prototype.from = new Proxy( - (SupabaseClient as SupabaseClientConstructor).prototype.from, + (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from = new Proxy( + (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from, { apply(target, thisArg, argumentsList) { const rv = Reflect.apply(target, thisArg, argumentsList); @@ -640,7 +640,7 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void { }, ); - markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from); + markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from); } function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void { From 1d32d866d4d645e11d00026b0225c5c15c824fc3 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 27 Jun 2025 14:30:09 +0100 Subject: [PATCH 11/17] Extract `instrumentRpcMethod` --- packages/core/src/integrations/supabase.ts | 45 +++++++++------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index c93edc9c5fdd..89945624641c 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -233,44 +233,37 @@ function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema)) { return; } - (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema = new Proxy( (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema, { apply(target, thisArg, argumentsList) { const supabaseInstance = Reflect.apply(target, thisArg, argumentsList); - - (supabaseInstance as unknown as SupabaseClientConstructorType).rpc = new Proxy( - (supabaseInstance as unknown as SupabaseClientInstance).rpc, - { - apply(target, thisArg, argumentsList) { - const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; - const isConsumerSpan = argumentsList[0] === 'pop'; - - if (!isProducerSpan && !isConsumerSpan) { - return Reflect.apply(target, thisArg, argumentsList); - } - - if (isProducerSpan) { - return instrumentRpcProducer(target, thisArg, argumentsList); - } else if (isConsumerSpan) { - return instrumentRpcConsumer(target, thisArg, argumentsList); - } - - // If the operation is not a queue operation, return the original function - return Reflect.apply(target, thisArg, argumentsList); - }, - }, - ); - + instrumentRpcMethod(supabaseInstance as unknown as SupabaseClientConstructorType); return supabaseInstance; }, }, ); - markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema); } +function instrumentRpcMethod(supabaseInstance: SupabaseClientConstructorType): void { + supabaseInstance.rpc = new Proxy((supabaseInstance as unknown as SupabaseClientInstance).rpc, { + apply(target, thisArg, argumentsList) { + const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; + const isConsumerSpan = argumentsList[0] === 'pop'; + if (!isProducerSpan && !isConsumerSpan) { + return Reflect.apply(target, thisArg, argumentsList); + } + if (isProducerSpan) { + return instrumentRpcProducer(target, thisArg, argumentsList); + } else if (isConsumerSpan) { + return instrumentRpcConsumer(target, thisArg, argumentsList); + } + return Reflect.apply(target, thisArg, argumentsList); + }, + }); +} + function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: string; baggage?: string } }): { sentryTrace?: string; baggage?: string; From d650de1659cf3156ebb287cead35c5bd2c08ffb2 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 19 Aug 2025 23:18:05 +0100 Subject: [PATCH 12/17] WIP --- packages/core/src/integrations/supabase.ts | 206 +++++++++++++-------- 1 file changed, 128 insertions(+), 78 deletions(-) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 89945624641c..0fe450d7d1b0 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -277,6 +277,16 @@ function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: return {}; } +/** + * Instruments the RPC consumer methods of a Supabase client. + * + * A span is only created when we can match the consumer operation to its corresponding producer span. + * + * @param target - The original function to instrument. + * @param thisArg - The context to bind the function to. + * @param argumentsList - The arguments to pass to the function. + * @returns A promise that resolves with the result of the original function. + */ const instrumentRpcConsumer = (target: any, thisArg: any, argumentsList: any[]): Promise => { const [operationName, queueParams] = argumentsList as [ 'pop', @@ -292,102 +302,136 @@ const instrumentRpcConsumer = (target: any, thisArg: any, argumentsList: any[]): return Reflect.apply(target, thisArg, argumentsList); // Not a consumer operation } - return (Reflect.apply(target, thisArg, argumentsList) as Promise).then((res: SupabaseResponse) => { - const latency = res.data?.[0]?.enqueued_at ? Date.now() - Date.parse(res.data?.[0]?.enqueued_at) : undefined; - - const { sentryTrace, baggage } = extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); - - // Remove Sentry metadata from the returned message - delete res.data?.[0]?.message?._sentry; - - return continueTrace( - { - sentryTrace, - baggage, + return startSpan( + { + name: 'supabase.queue.receive', + op: 'queue.process', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + 'messaging.system': 'supabase', }, - () => { - return startSpan( - { - name: 'supabase.db.rpc', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - 'messaging.system': 'supabase', - }, - }, - span => { - const messageId = - res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; + }, + async (span) => { + try { + // Call the original function + const res = await Reflect.apply(target, thisArg, argumentsList) as SupabaseResponse; - if (messageId) { - span.setAttribute('messaging.message.id', messageId); - } + // Calculate latency if possible + const latency = res.data?.[0]?.enqueued_at ? Date.now() - Date.parse(res.data?.[0]?.enqueued_at) : undefined; - if (queueName) { - span.setAttribute('messaging.destination.name', queueName); - } + // Extract trace context + const { sentryTrace, baggage } = extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); - if (latency) { - span.setAttribute('messaging.message.receive.latency', latency); - } - - const breadcrumb: SupabaseBreadcrumb = { - type: 'supabase', - category: `db.rpc.${argumentsList[0]}`, - message: `rpc(${argumentsList[0]})`, - }; + // Remove Sentry metadata from the returned message + delete res.data?.[0]?.message?._sentry; - const data: Record = {}; + // Get message ID if available + const messageId = res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; - if (messageId) { - data['messaging.message.id'] = messageId; - } + // Set span attributes + if (messageId) { + span.setAttribute('messaging.message.id', messageId); + } - if (queueName) { - data['messaging.destination.name'] = queueName; - } + if (queueName) { + span.setAttribute('messaging.destination.name', queueName); + } - if (Object.keys(data).length) { - breadcrumb.data = data; - } + if (latency) { + span.setAttribute('messaging.message.receive.latency', latency); + } - addBreadcrumb(breadcrumb); + // Add breadcrumb for monitoring + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.rpc.${argumentsList[0]}`, + message: `rpc(${argumentsList[0]})`, + }; - if (res.error) { - const err = new Error(res.error.message) as SupabaseError; + const data: Record = {}; + if (messageId) data['messaging.message.id'] = messageId; + if (queueName) data['messaging.destination.name'] = queueName; + if (Object.keys(data).length) breadcrumb.data = data; - if (res.error.code) { - err.code = res.error.code; - } + addBreadcrumb(breadcrumb); - if (res.error.details) { - err.details = res.error.details; - } + // Handle errors in the response + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + if (res.error.code) err.code = res.error.code; + if (res.error.details) err.details = res.error.details; - captureException(err, { - contexts: { - supabase: { - queueName, - messageId, - }, - }, - }); + captureException(err, { + contexts: { + supabase: { queueName, messageId }, + }, + }); - span.setStatus({ code: SPAN_STATUS_ERROR }); - } else { - span.setStatus({ code: SPAN_STATUS_OK }); - } + span.setStatus({ code: SPAN_STATUS_ERROR }); + } else { + span.setStatus({ code: SPAN_STATUS_OK }); + } - span.end(); + // Continue trace if we have the trace context + if (sentryTrace || baggage) { + return continueTrace( + { sentryTrace, baggage }, + () => startSpan( + { + name: 'supabase.db.rpc', + op: 'queue.process', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + 'messaging.system': 'supabase', + }, + }, + processSpan => { + if (messageId) { + processSpan.setAttribute('messaging.message.id', messageId); + } + + if (queueName) { + processSpan.setAttribute('messaging.destination.name', queueName); + } + + if (latency) { + processSpan.setAttribute('messaging.message.receive.latency', latency); + } + + if (res.error) { + processSpan.setStatus({ code: SPAN_STATUS_ERROR }); + } else { + processSpan.setStatus({ code: SPAN_STATUS_OK }); + } + + processSpan.end(); + return res; + } + ) + ); + } - return res; - }, - ); - }, - ); - }); + return res; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw error; + } finally { + span.end(); + } + } + ); }; +/** + * Instruments the RPC producer methods of a Supabase client. + * + * @param target - The original function to instrument. + * @param thisArg - The context to bind the function to. + * @param argumentsList - The arguments to pass to the function. + * @returns A promise that resolves with the result of the original function. + */ function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): Promise { const maybeQueueParams = argumentsList[1]; @@ -507,6 +551,11 @@ function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): ); } +/** + * Instruments the RPC methods of a Supabase client. + * + * @param SupabaseClient - The Supabase client instance to instrument. + */ function instrumentRpc(SupabaseClient: unknown): void { (SupabaseClient as unknown as SupabaseClientInstance).rpc = new Proxy( (SupabaseClient as unknown as SupabaseClientInstance).rpc, @@ -514,6 +563,7 @@ function instrumentRpc(SupabaseClient: unknown): void { apply(target, thisArg, argumentsList) { let result: Promise; + // Check if the first argument is 'send', 'send_batch', or 'pop' to determine if it's a producer or consumer operation if (argumentsList[0] === 'send' || argumentsList[0] === 'send_batch') { result = instrumentRpcProducer(target, thisArg, argumentsList); } else if (argumentsList[0] === 'pop') { From 9accd4b55a60368d91b5cb2068a10f1fc9e61aa0 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Nov 2025 16:24:46 +0000 Subject: [PATCH 13/17] Match with OTEL semantics improve tests --- .../pages/api/queue/batch-flow.ts | 85 ++ .../pages/api/queue/concurrent-operations.ts | 111 ++ .../pages/api/queue/error-flow.ts | 78 + .../pages/api/queue/producer-consumer-flow.ts | 67 + .../supabase-nextjs/pages/api/rpc/status.ts | 21 + .../20250515080602_enable-queues.sql | 68 +- .../supabase-nextjs/tests/performance.test.ts | 492 ++++++- packages/core/src/integrations/supabase.ts | 1264 +++++++++++------ .../lib/integrations/supabase-queues.test.ts | 1070 ++++++++++++++ 9 files changed, 2774 insertions(+), 482 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/batch-flow.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/concurrent-operations.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/error-flow.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-consumer-flow.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/rpc/status.ts create mode 100644 packages/core/test/lib/integrations/supabase-queues.test.ts diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/batch-flow.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/batch-flow.ts new file mode 100644 index 000000000000..478279e8ea2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/batch-flow.ts @@ -0,0 +1,85 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Step 1: Batch produce multiple messages + const { data: sendData, error: sendError } = await supabaseClient.rpc('send_batch', { + queue_name: 'batch-flow-queue', + messages: [ + { + taskType: 'email', + recipient: 'user1@example.com', + subject: 'Welcome!', + }, + { + taskType: 'email', + recipient: 'user2@example.com', + subject: 'Verification', + }, + { + taskType: 'sms', + recipient: '+1234567890', + message: 'Your code is 123456', + }, + ], + }); + + if (sendError) { + return res.status(500).json({ error: `Send batch failed: ${sendError.message}` }); + } + + // Step 2: Consume multiple messages from the queue + const { data: receiveData, error: receiveError } = await supabaseClient.rpc('receive', { + queue_name: 'batch-flow-queue', + vt: 30, + qty: 3, + }); + + if (receiveError) { + return res.status(500).json({ error: `Receive failed: ${receiveError.message}` }); + } + + // Step 3: Process all messages + const processedMessages = receiveData?.map((msg: any) => ({ + messageId: msg.msg_id, + taskType: msg.message?.taskType, + processed: true, + })); + + // Step 4: Archive all processed messages + const messageIds = receiveData?.map((msg: any) => msg.msg_id).filter(Boolean); + if (messageIds && messageIds.length > 0) { + const { error: archiveError } = await supabaseClient.rpc('archive', { + queue_name: 'batch-flow-queue', + msg_ids: messageIds, + }); + + if (archiveError) { + return res.status(500).json({ error: `Archive failed: ${archiveError.message}` }); + } + } + + return res.status(200).json({ + success: true, + batchSize: 3, + produced: { messageIds: sendData }, + consumed: { + count: receiveData?.length || 0, + messages: processedMessages, + }, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/concurrent-operations.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/concurrent-operations.ts new file mode 100644 index 000000000000..aed1b7e223bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/concurrent-operations.ts @@ -0,0 +1,111 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Test concurrent queue operations to multiple queues + // This validates that instrumentation handles parallel operations correctly + + try { + // Produce messages to 3 different queues concurrently + const produceOperations = await Promise.all([ + supabaseClient.rpc('send', { + queue_name: 'concurrent-queue-1', + message: { queueId: 1, task: 'process-images' }, + }), + supabaseClient.rpc('send', { + queue_name: 'concurrent-queue-2', + message: { queueId: 2, task: 'send-emails' }, + }), + supabaseClient.rpc('send', { + queue_name: 'concurrent-queue-3', + message: { queueId: 3, task: 'generate-reports' }, + }), + ]); + + // Check for errors + const produceErrors = produceOperations + .map((op, idx) => (op.error ? { queue: idx + 1, error: op.error.message } : null)) + .filter(Boolean); + + if (produceErrors.length > 0) { + return res.status(500).json({ error: 'Some produce operations failed', details: produceErrors }); + } + + // Consume from all queues concurrently + const consumeOperations = await Promise.all([ + supabaseClient.rpc('receive', { + queue_name: 'concurrent-queue-1', + vt: 30, + qty: 1, + }), + supabaseClient.rpc('receive', { + queue_name: 'concurrent-queue-2', + vt: 30, + qty: 1, + }), + supabaseClient.rpc('receive', { + queue_name: 'concurrent-queue-3', + vt: 30, + qty: 1, + }), + ]); + + // Process results + const consumeErrors = consumeOperations + .map((op, idx) => (op.error ? { queue: idx + 1, error: op.error.message } : null)) + .filter(Boolean); + + if (consumeErrors.length > 0) { + return res.status(500).json({ error: 'Some consume operations failed', details: consumeErrors }); + } + + // Archive all messages concurrently + const messageIds = consumeOperations.map((op, idx) => ({ + queue: `concurrent-queue-${idx + 1}`, + msgId: op.data?.[0]?.msg_id, + })); + + await Promise.all( + messageIds + .filter(m => m.msgId) + .map(m => + supabaseClient.rpc('archive', { + queue_name: m.queue, + msg_ids: [m.msgId], + }), + ), + ); + + return res.status(200).json({ + success: true, + concurrentOperations: { + queuesProcessed: 3, + produced: produceOperations.map(op => op.data), + consumed: consumeOperations.map((op, idx) => ({ + queue: idx + 1, + messageId: op.data?.[0]?.msg_id, + task: op.data?.[0]?.message?.task, + })), + }, + }); + } catch (error) { + Sentry.captureException(error); + return res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/error-flow.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/error-flow.ts new file mode 100644 index 000000000000..4abbf752676d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/error-flow.ts @@ -0,0 +1,78 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Step 1: Produce a message that will cause processing error + const { data: sendData, error: sendError } = await supabaseClient.rpc('send', { + queue_name: 'error-flow-queue', + message: { + action: 'divide', + numerator: 100, + denominator: 0, // This will cause an error + }, + }); + + if (sendError) { + return res.status(500).json({ error: `Send failed: ${sendError.message}` }); + } + + // Step 2: Consume the message + const { data: receiveData, error: receiveError } = await supabaseClient.rpc('receive', { + queue_name: 'error-flow-queue', + vt: 30, + qty: 1, + }); + + if (receiveError) { + return res.status(500).json({ error: `Receive failed: ${receiveError.message}` }); + } + + // Step 3: Process the message - this will throw an error + const message = receiveData?.[0]; + + try { + if (message?.message?.denominator === 0) { + throw new Error('Division by zero error in queue processor'); + } + + // Simulate successful processing (won't be reached in this flow) + const result = message.message.numerator / message.message.denominator; + + return res.status(200).json({ + success: true, + result, + messageId: message?.msg_id, + }); + } catch (error) { + // Capture the error with Sentry + Sentry.captureException(error, scope => { + scope.setContext('queue', { + queueName: 'error-flow-queue', + messageId: message?.msg_id, + message: message?.message, + }); + return scope; + }); + + // Return error response + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + messageId: message?.msg_id, + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-consumer-flow.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-consumer-flow.ts new file mode 100644 index 000000000000..b9cae805fadb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/queue/producer-consumer-flow.ts @@ -0,0 +1,67 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + db: { + schema: 'pgmq_public', + }, +}); + +Sentry.instrumentSupabaseClient(supabaseClient); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Step 1: Produce a message to the queue + const { data: sendData, error: sendError } = await supabaseClient.rpc('send', { + queue_name: 'e2e-flow-queue', + message: { + action: 'process_order', + orderId: 'ORDER-123', + timestamp: new Date().toISOString(), + }, + }); + + if (sendError) { + return res.status(500).json({ error: `Send failed: ${sendError.message}` }); + } + + // Step 2: Consume the message from the queue (with VT=30 seconds) + const { data: receiveData, error: receiveError } = await supabaseClient.rpc('receive', { + queue_name: 'e2e-flow-queue', + vt: 30, + qty: 1, + }); + + if (receiveError) { + return res.status(500).json({ error: `Receive failed: ${receiveError.message}` }); + } + + // Step 3: Process the message (simulate business logic) + const processedMessage = receiveData?.[0]; + + // Step 4: Archive the message after successful processing + if (processedMessage?.msg_id) { + const { error: archiveError } = await supabaseClient.rpc('archive', { + queue_name: 'e2e-flow-queue', + msg_ids: [processedMessage.msg_id], + }); + + if (archiveError) { + return res.status(500).json({ error: `Archive failed: ${archiveError.message}` }); + } + } + + return res.status(200).json({ + success: true, + produced: { messageId: sendData }, + consumed: { + messageId: processedMessage?.msg_id, + message: processedMessage?.message, + }, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/rpc/status.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/rpc/status.ts new file mode 100644 index 000000000000..d8c6119b1701 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/rpc/status.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSupabaseClient } from '@/lib/initSupabaseAdmin'; + +const supabaseClient = getSupabaseClient(); + +type Data = { + data: unknown; + error: unknown; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { data, error } = await supabaseClient.rpc('get_supabase_status'); + + if (error) { + console.warn('Supabase RPC status check failed', error); + res.status(500).json({ data, error }); + return; + } + + res.status(200).json({ data, error }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql index 8eba5c8de3a4..6f97483b33d7 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20250515080602_enable-queues.sql @@ -78,22 +78,27 @@ comment on function pgmq_public.send_batch(queue_name text, messages jsonb[], sl create or replace function pgmq_public.archive( queue_name text, - message_id bigint + msg_ids bigint[] ) returns boolean language plpgsql set search_path = '' as $$ +declare + msg_id bigint; + success boolean := true; begin - return - pgmq.archive( - queue_name := queue_name, - msg_id := message_id - ); + foreach msg_id in array msg_ids + loop + if not pgmq.archive(queue_name := queue_name, msg_id := msg_id) then + success := false; + end if; + end loop; + return success; end; $$; -comment on function pgmq_public.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; +comment on function pgmq_public.archive(queue_name text, msg_ids bigint[]) is 'Archives multiple messages by moving them from the queue to a permanent archive.'; create or replace function pgmq_public.delete( @@ -137,6 +142,29 @@ $$; comment on function pgmq_public.read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; +-- Create receive function (alias for read with different parameter names for E2E test compatibility) +create or replace function pgmq_public.receive( + queue_name text, + vt integer, + qty integer +) + returns setof pgmq.message_record + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.read( + queue_name := queue_name, + vt := vt, + qty := qty + ); +end; +$$; + +comment on function pgmq_public.receive(queue_name text, vt integer, qty integer) is 'Alias for read() - reads messages from the specified queue with visibility timeout.'; + -- Grant execute permissions on wrapper functions to roles grant execute on function pgmq_public.pop(text) to postgres, service_role, anon, authenticated; grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated; @@ -147,14 +175,14 @@ grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_r grant execute on function pgmq_public.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; -grant execute on function pgmq_public.archive(text, bigint) to postgres, service_role, anon, authenticated; -grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated; +grant execute on function pgmq_public.receive(text, integer, integer) to postgres, service_role, anon, authenticated; + +grant execute on function pgmq_public.archive(text, bigint[]) to postgres, service_role, anon, authenticated; grant execute on function pgmq_public.delete(text, bigint) to postgres, service_role, anon, authenticated; grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated; grant execute on function pgmq_public.read(text, integer, integer) to postgres, service_role, anon, authenticated; -grant execute on function pgmq.read(text, integer, integer) to postgres, service_role, anon, authenticated; -- For the service role, we want full access -- Grant permissions on existing tables @@ -180,3 +208,23 @@ alter default privileges in schema pgmq grant usage, select, update on sequences to anon, authenticated, service_role; + +-- Create additional queues for E2E flow tests +select pgmq.create('e2e-flow-queue'); +select pgmq.create('batch-flow-queue'); +select pgmq.create('error-flow-queue'); +select pgmq.create('concurrent-queue-1'); +select pgmq.create('concurrent-queue-2'); +select pgmq.create('concurrent-queue-3'); + +-- Lightweight RPC used by tests to verify non-queue instrumentation +create or replace function public.get_supabase_status() +returns jsonb +language sql +stable +as +$$ + select jsonb_build_object('status', 'ok'); +$$; + +grant execute on function public.get_supabase_status() to authenticated, anon; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index c91e9f50bb98..f47a79ad6e64 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -35,6 +35,55 @@ test('Sends server-side Supabase auth admin `createUser` span', async ({ page, b }); }); +test('Sends server-side Supabase RPC spans and breadcrumbs', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return Boolean( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/rpc/status', + ); + }); + + const result = await fetch(`${baseURL}/api/rpc/status`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + + const responseBody = await result.json(); + expect(responseBody.error).toBeNull(); + expect(responseBody.data).toEqual({ status: 'ok' }); + + const rpcSpan = transactionEvent.spans?.find( + span => + span?.op === 'db' && + typeof span?.description === 'string' && + span.description.includes('get_supabase_status') && + span?.data?.['sentry.origin'] === 'auto.db.supabase', + ); + + expect(rpcSpan).toBeDefined(); + expect(rpcSpan?.data).toEqual( + expect.objectContaining({ + 'db.operation': 'insert', + 'db.table': 'get_supabase_status', + 'db.system': 'postgresql', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.supabase', + }), + ); + expect(rpcSpan?.description).toContain('get_supabase_status'); + + expect(transactionEvent.breadcrumbs).toBeDefined(); + expect( + transactionEvent.breadcrumbs?.some( + breadcrumb => + breadcrumb?.type === 'supabase' && + breadcrumb?.category === 'db.insert' && + typeof breadcrumb?.message === 'string' && + breadcrumb.message.includes('get_supabase_status'), + ), + ).toBe(true); +}); + test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', async ({ page, baseURL }) => { const pageloadTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; @@ -232,12 +281,15 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '1', + 'messaging.operation.type': 'publish', + 'messaging.operation.name': 'send', + 'messaging.message.body.size': expect.any(Number), 'sentry.op': 'queue.publish', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.producer', }, - description: 'supabase.db.rpc', + description: 'publish todos', op: 'queue.publish', - origin: 'auto.db.supabase', + origin: 'auto.db.supabase.queue.producer', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -249,8 +301,8 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', - category: 'db.rpc.send', - message: 'rpc(send)', + category: 'queue.publish', + message: 'queue.publish(todos)', data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '1', @@ -278,12 +330,15 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '2', + 'messaging.operation.type': 'publish', + 'messaging.operation.name': 'send', + 'messaging.message.body.size': expect.any(Number), 'sentry.op': 'queue.publish', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.producer', }, - description: 'supabase.db.rpc', + description: 'publish todos', op: 'queue.publish', - origin: 'auto.db.supabase', + origin: 'auto.db.supabase.queue.producer', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -295,8 +350,8 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', - category: 'db.rpc.send', - message: 'rpc(send)', + category: 'queue.publish', + message: 'queue.publish(todos)', data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '2', @@ -305,17 +360,37 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { }); test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, baseURL }) => { - const consumerSpanPromise = waitForTransaction('supabase-nextjs', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'queue.process' && transactionEvent?.transaction === 'supabase.db.rpc' + const producerTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return Boolean( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/producer-schema' && + transactionEvent?.spans?.some((span: any) => span.op === 'queue.publish'), + ); + }); + + await fetch(`${baseURL}/api/queue/producer-schema`); + const producerTransaction = await producerTransactionPromise; + + const producerSpan = producerTransaction.spans?.find(span => span.op === 'queue.publish'); + expect(producerSpan).toBeDefined(); + + // Wait a bit for the message to be in the queue + await new Promise(resolve => setTimeout(resolve, 100)); + + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return Boolean( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/consumer-schema' && + transactionEvent?.spans?.some((span: any) => span.op === 'queue.process'), ); }); const result = await fetch(`${baseURL}/api/queue/consumer-schema`); - const consumerEvent = await consumerSpanPromise; + const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); - expect(await result.json()).toEqual( + const responseData = await result.json(); + expect(responseData).toEqual( expect.objectContaining({ data: [ expect.objectContaining({ @@ -328,29 +403,59 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas }), ); - expect(consumerEvent.contexts.trace).toEqual({ - data: { + // CRITICAL: Verify _sentry metadata is cleaned up from response + const queueMessage = responseData.data?.[0]; + expect(queueMessage).toBeDefined(); + expect(queueMessage.message).toBeDefined(); + expect(queueMessage.message._sentry).toBeUndefined(); + + const consumerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.process' && span.description === 'process todos', + ); + expect(consumerSpan).toBeDefined(); + + expect(consumerSpan).toMatchObject({ + data: expect.objectContaining({ 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '1', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'pop', + 'messaging.message.body.size': expect.any(Number), 'messaging.message.receive.latency': expect.any(Number), + 'messaging.message.retry.count': expect.any(Number), 'sentry.op': 'queue.process', - 'sentry.origin': 'auto.db.supabase', - 'sentry.source': 'route', - }, + 'sentry.origin': 'auto.db.supabase.queue.consumer', + }), + description: 'process todos', op: 'queue.process', - origin: 'auto.db.supabase', - parent_span_id: expect.any(String), - span_id: expect.any(String), + origin: 'auto.db.supabase.queue.consumer', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), status: 'ok', - trace_id: expect.any(String), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Verify span link for distributed tracing across separate HTTP requests + expect(consumerSpan?.links).toBeDefined(); + expect(consumerSpan?.links?.length).toBeGreaterThanOrEqual(1); + + const producerLink = consumerSpan?.links?.[0]; + expect(producerLink).toMatchObject({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + attributes: { + 'sentry.link.type': 'queue.producer', + }, }); - expect(consumerEvent.breadcrumbs).toContainEqual({ + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', - category: 'db.rpc.pop', - message: 'rpc(pop)', + category: 'queue.process', + message: 'queue.process(todos)', data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '1', @@ -359,17 +464,37 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas }); test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { - const consumerSpanPromise = waitForTransaction('supabase-nextjs', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'queue.process' && transactionEvent?.transaction === 'supabase.db.rpc' + const producerTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return !!( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/producer-rpc' && + transactionEvent?.spans?.some((span: any) => span.op === 'queue.publish') + ); + }); + + await fetch(`${baseURL}/api/queue/producer-rpc`); + const producerTransaction = await producerTransactionPromise; + + const producerSpan = producerTransaction.spans?.find(span => span.op === 'queue.publish'); + expect(producerSpan).toBeDefined(); + + // Wait a bit for the message to be in the queue + await new Promise(resolve => setTimeout(resolve, 100)); + + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return !!( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/consumer-rpc' && + transactionEvent?.spans?.some((span: any) => span.op === 'queue.process') ); }); const result = await fetch(`${baseURL}/api/queue/consumer-rpc`); - const consumerEvent = await consumerSpanPromise; + const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); - expect(await result.json()).toEqual( + const responseData = await result.json(); + expect(responseData).toEqual( expect.objectContaining({ data: [ expect.objectContaining({ @@ -381,29 +506,60 @@ test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { ], }), ); - expect(consumerEvent.contexts.trace).toEqual({ - data: { + + // CRITICAL: Verify _sentry metadata is cleaned up from response + const queueMessage = responseData.data?.[0]; + expect(queueMessage).toBeDefined(); + expect(queueMessage.message).toBeDefined(); + expect(queueMessage.message._sentry).toBeUndefined(); + + const consumerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.process' && span.data?.['messaging.message.id'] === '2', + ); + expect(consumerSpan).toBeDefined(); + + expect(consumerSpan).toMatchObject({ + data: expect.objectContaining({ 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', 'messaging.message.id': '2', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'pop', + 'messaging.message.body.size': expect.any(Number), 'messaging.message.receive.latency': expect.any(Number), + 'messaging.message.retry.count': expect.any(Number), 'sentry.op': 'queue.process', - 'sentry.origin': 'auto.db.supabase', - 'sentry.source': 'route', - }, + 'sentry.origin': 'auto.db.supabase.queue.consumer', + }), + description: 'process todos', op: 'queue.process', - origin: 'auto.db.supabase', - parent_span_id: expect.any(String), - span_id: expect.any(String), + origin: 'auto.db.supabase.queue.consumer', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), status: 'ok', - trace_id: expect.any(String), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); - expect(consumerEvent.breadcrumbs).toContainEqual({ + // Verify span link for distributed tracing across separate HTTP requests + expect(consumerSpan?.links).toBeDefined(); + expect(consumerSpan?.links?.length).toBeGreaterThanOrEqual(1); + + const producerLink = consumerSpan?.links?.[0]; + expect(producerLink).toMatchObject({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + attributes: { + 'sentry.link.type': 'queue.producer', + }, + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', - category: 'db.rpc.pop', - message: 'rpc(pop)', + category: 'queue.process', + message: 'queue.process(todos)', data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '2', @@ -413,14 +569,14 @@ test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { test('Sends queue process error spans with `rpc(...)`', async ({ page, baseURL }) => { const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { - return ( + return Boolean( transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /api/queue/consumer-error' + transactionEvent?.transaction === 'GET /api/queue/consumer-error', ); }); const errorEventPromise = waitForError('supabase-nextjs', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value?.includes('pgmq.q_non-existing-queue'); + return Boolean(errorEvent?.exception?.values?.[0]?.value?.includes('pgmq.q_non-existing-queue')); }); const result = await fetch(`${baseURL}/api/queue/consumer-error`); @@ -444,31 +600,36 @@ test('Sends queue process error spans with `rpc(...)`', async ({ page, baseURL } expect(errorEvent.breadcrumbs).toContainEqual( expect.objectContaining({ type: 'supabase', - category: 'db.rpc.pop', - message: 'rpc(pop)', + category: 'queue.process', + message: 'queue.process(non-existing-queue)', data: { 'messaging.destination.name': 'non-existing-queue', }, }), ); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'messaging.destination.name': 'non-existing-queue', - 'messaging.system': 'supabase', - 'sentry.op': 'queue.process', - 'sentry.origin': 'auto.db.supabase', - }, - description: 'supabase.db.rpc', - op: 'queue.process', - origin: 'auto.db.supabase', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'unknown_error', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'messaging.destination.name': 'non-existing-queue', + 'messaging.system': 'supabase', + 'messaging.operation.type': 'process', + 'messaging.operation.name': 'pop', + 'messaging.message.retry.count': expect.any(Number), + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.db.supabase.queue.consumer', + }), + description: 'process non-existing-queue', + op: 'queue.process', + origin: 'auto.db.supabase.queue.consumer', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'internal_error', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); }); test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL }) => { @@ -483,20 +644,28 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); - expect(await result.json()).toEqual({ data: [3, 4] }); + const responseData = await result.json(); + expect(responseData).toEqual({ + data: expect.arrayContaining([expect.any(Number), expect.any(Number)]), + }); + expect(responseData.data).toHaveLength(2); expect(transactionEvent.spans).toHaveLength(2); expect(transactionEvent.spans).toContainEqual({ data: { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', - 'messaging.message.id': '3,4', + 'messaging.message.id': expect.stringMatching(/^\d+,\d+$/), + 'messaging.operation.type': 'publish', + 'messaging.operation.name': 'send_batch', + 'messaging.batch.message_count': 2, + 'messaging.message.body.size': expect.any(Number), 'sentry.op': 'queue.publish', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.producer', }, - description: 'supabase.db.rpc', + description: 'publish todos', op: 'queue.publish', - origin: 'auto.db.supabase', + origin: 'auto.db.supabase.queue.producer', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -508,11 +677,186 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', - category: 'db.rpc.send_batch', - message: 'rpc(send_batch)', + category: 'queue.publish', + message: 'queue.publish(todos)', data: { 'messaging.destination.name': 'todos', - 'messaging.message.id': '3,4', + 'messaging.message.id': expect.stringMatching(/^\d+,\d+$/), + 'messaging.batch.message_count': 2, + }, + }); +}); + +test('End-to-end producer-consumer flow with trace propagation', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/producer-consumer-flow' + ); + }); + + const result = await fetch(`${baseURL}/api/queue/producer-consumer-flow`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + const body = await result.json(); + expect(body.success).toBe(true); + expect(body.produced.messageId).toBeDefined(); + expect(body.consumed.messageId).toBeDefined(); + + // Should have producer span, consumer span, and archive RPC span + expect(transactionEvent.spans?.length).toBeGreaterThanOrEqual(3); + + const producerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.publish' && span.data?.['messaging.destination.name'] === 'e2e-flow-queue', + ); + expect(producerSpan).toBeDefined(); + expect(producerSpan?.origin).toBe('auto.db.supabase.queue.producer'); + expect(producerSpan?.data?.['messaging.system']).toBe('supabase'); + expect(producerSpan?.data?.['messaging.message.id']).toBeDefined(); + + const consumerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.process' && span.data?.['messaging.destination.name'] === 'e2e-flow-queue', + ); + expect(consumerSpan).toBeDefined(); + expect(consumerSpan?.origin).toBe('auto.db.supabase.queue.consumer'); + expect(consumerSpan?.data?.['messaging.system']).toBe('supabase'); + expect(consumerSpan?.data?.['messaging.message.id']).toBeDefined(); + expect(consumerSpan?.data?.['messaging.message.receive.latency']).toBeDefined(); + + // Verify all spans share the same trace_id within the HTTP transaction + expect(producerSpan?.trace_id).toBe(consumerSpan?.trace_id); + expect(producerSpan?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + + // Producer and consumer are siblings under the HTTP transaction + // Both are direct children of the HTTP request span, not parent-child of each other + const httpTransactionSpanId = transactionEvent.contexts?.trace?.span_id; + expect(producerSpan?.parent_span_id).toBe(httpTransactionSpanId); + expect(consumerSpan?.parent_span_id).toBe(httpTransactionSpanId); + + // Verify consumer span has a span link to producer span + // This creates a logical association between producer and consumer operations + // without making them parent-child (they're siblings in the same trace) + expect(consumerSpan?.links).toBeDefined(); + expect(consumerSpan?.links?.length).toBe(1); + + // Verify the span link points to the producer span + const producerLink = consumerSpan?.links?.[0]; + expect(producerLink).toMatchObject({ + trace_id: producerSpan?.trace_id, + span_id: producerSpan?.span_id, + attributes: { + 'sentry.link.type': 'queue.producer', }, }); + + // Producer spans don't have links (only consumers link to producers) + expect(producerSpan?.links).toBeUndefined(); +}); + +test('Batch producer-consumer flow with multiple messages', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/batch-flow' + ); + }); + + const result = await fetch(`${baseURL}/api/queue/batch-flow`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + const body = await result.json(); + expect(body.success).toBe(true); + expect(body.batchSize).toBe(3); + expect(body.consumed.count).toBe(3); + + expect(transactionEvent.spans).toBeDefined(); + const producerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.publish' && span.data?.['messaging.destination.name'] === 'batch-flow-queue', + ); + expect(producerSpan).toBeDefined(); + expect(producerSpan?.origin).toBe('auto.db.supabase.queue.producer'); + expect(producerSpan?.data?.['messaging.batch.message_count']).toBe(3); + expect(producerSpan?.data?.['messaging.message.id']).toMatch(/,/); // Should have multiple IDs + + const consumerSpan = transactionEvent.spans?.find( + span => span.op === 'queue.process' && span.data?.['messaging.destination.name'] === 'batch-flow-queue', + ); + expect(consumerSpan).toBeDefined(); + expect(consumerSpan?.origin).toBe('auto.db.supabase.queue.consumer'); + expect(consumerSpan?.data?.['messaging.message.id']).toMatch(/,/); // Multiple IDs consumed +}); + +test('Queue error handling and error capture', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/error-flow' + ); + }); + + const errorEventPromise = waitForError('supabase-nextjs', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('Division by zero error in queue processor'); + }); + + const result = await fetch(`${baseURL}/api/queue/error-flow`); + const transactionEvent = await httpTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(result.status).toBe(500); + const body = await result.json(); + expect(body.success).toBe(false); + expect(body.error).toContain('Division by zero'); + + expect(errorEvent).toBeDefined(); + expect(errorEvent?.contexts?.queue).toBeDefined(); + expect(errorEvent?.contexts?.queue?.queueName).toBe('error-flow-queue'); + expect(errorEvent?.contexts?.queue?.messageId).toBeDefined(); + + // Verify queue spans were still created despite error + expect(transactionEvent.spans).toBeDefined(); + const producerSpan = transactionEvent.spans?.find(span => span.op === 'queue.publish'); + expect(producerSpan).toBeDefined(); + + const consumerSpan = transactionEvent.spans?.find(span => span.op === 'queue.process'); + expect(consumerSpan).toBeDefined(); +}); + +test('Concurrent queue operations across multiple queues', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/queue/concurrent-operations' + ); + }); + + const result = await fetch(`${baseURL}/api/queue/concurrent-operations`); + const transactionEvent = await httpTransactionPromise; + + expect(result.status).toBe(200); + const body = await result.json(); + expect(body.success).toBe(true); + expect(body.concurrentOperations.queuesProcessed).toBe(3); + + // Should have spans for 3 producer operations and 3 consumer operations + expect(transactionEvent.spans).toBeDefined(); + const producerSpans = transactionEvent.spans?.filter(span => span.op === 'queue.publish') || []; + const consumerSpans = transactionEvent.spans?.filter(span => span.op === 'queue.process') || []; + + expect(producerSpans.length).toBe(3); + expect(consumerSpans.length).toBe(3); + + // Verify each queue has its own spans + const queue1Producer = producerSpans.find(span => span.data?.['messaging.destination.name'] === 'concurrent-queue-1'); + const queue2Producer = producerSpans.find(span => span.data?.['messaging.destination.name'] === 'concurrent-queue-2'); + const queue3Producer = producerSpans.find(span => span.data?.['messaging.destination.name'] === 'concurrent-queue-3'); + + expect(queue1Producer).toBeDefined(); + expect(queue2Producer).toBeDefined(); + expect(queue3Producer).toBeDefined(); + + // All spans should have the same trace_id (part of same transaction) + expect(queue1Producer?.trace_id).toBe(queue2Producer?.trace_id); + expect(queue2Producer?.trace_id).toBe(queue3Producer?.trace_id); }); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 0fe450d7d1b0..dcdbd05bc639 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -3,16 +3,25 @@ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; +import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; -import { continueTrace, setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; +import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; +import { + getDynamicSamplingContextFromClient, + getDynamicSamplingContextFromSpan, +} from '../tracing/dynamicSamplingContext'; import type { IntegrationFn } from '../types-hoist/integration'; +import type { Span, SpanAttributes } from '../types-hoist/span'; +import { dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage'; import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; -import { getTraceData } from '../utils/traceData'; +import { spanToTraceContext, spanToTraceHeader } from '../utils/spanUtils'; +import { timestampInSeconds } from '../utils/time'; +import { extractTraceparentData } from '../utils/tracing'; export interface SupabaseClientConstructorType { prototype: { @@ -76,6 +85,10 @@ export const FILTER_MAPPINGS = { export const DB_OPERATIONS_TO_INSTRUMENT = ['select', 'insert', 'upsert', 'update', 'delete']; +const QUEUE_RPC_OPERATIONS = new Set(['send', 'send_batch', 'pop', 'receive', 'read']); + +const INTEGRATION_NAME = 'Supabase'; + type AuthOperationFn = (...args: unknown[]) => Promise; type AuthOperationName = (typeof AUTH_OPERATIONS_TO_INSTRUMENT)[number]; type AuthAdminOperationName = (typeof AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT)[number]; @@ -97,15 +110,18 @@ export interface PostgRESTFilterBuilder { headers: Record; url: URL; schema: string; - body: any; + body: unknown; } export interface SupabaseResponse { status?: number; data?: Array<{ msg_id?: number; + read_ct?: number; // PGMQ read count for retry tracking enqueued_at?: string; + vt?: number; // Visibility timeout message?: { + [key: string]: unknown; // Allow other message properties _sentry?: { sentry_trace?: string; baggage?: string; @@ -137,7 +153,7 @@ export interface SupabaseBreadcrumb { export interface PostgRESTProtoThenable { then: ( onfulfilled?: ((value: T) => T | PromiseLike) | null, - onrejected?: ((reason: any) => T | PromiseLike) | null, + onrejected?: ((reason: unknown) => T | PromiseLike) | null, ) => Promise; } @@ -145,7 +161,7 @@ type SentryInstrumented = T & { __SENTRY_INSTRUMENTED__?: boolean; }; -function markAsInstrumented(fn: T): void { +function _markAsInstrumented(fn: T): void { try { (fn as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; } catch { @@ -153,7 +169,7 @@ function markAsInstrumented(fn: T): void { } } -function isInstrumented(fn: T): boolean | undefined { +function _isInstrumented(fn: T): boolean | undefined { try { return (fn as SentryInstrumented).__SENTRY_INSTRUMENTED__; } catch { @@ -229,42 +245,113 @@ export function translateFiltersIntoMethods(key: string, query: string): string return `${method}(${key}, ${value.join('.')})`; } -function instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { - if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema)) { +/** + * Creates a proxy handler for RPC methods to instrument queue operations. + * This handler is shared between direct RPC calls and RPC calls via schema. + * + * @returns A proxy handler that routes queue operations to appropriate instrumentation + */ +function _createRpcProxyHandler(): ProxyHandler<(...args: unknown[]) => Promise> { + return { + apply( + target: (...args: unknown[]) => Promise, + thisArg: unknown, + argumentsList: unknown[], + ): Promise { + // Add try-catch for safety + try { + const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; + const isConsumerSpan = + argumentsList[0] === 'pop' || argumentsList[0] === 'receive' || argumentsList[0] === 'read'; + + if (!isProducerSpan && !isConsumerSpan) { + const result = Reflect.apply(target, thisArg, argumentsList); + + try { + if (result && typeof result === 'object') { + const builder = result as unknown as PostgRESTFilterBuilder; + const builderConstructor = builder?.constructor; + + if (typeof builderConstructor === 'function') { + _instrumentPostgRESTFilterBuilder( + builderConstructor as unknown as PostgRESTFilterBuilder['constructor'], + ); + } + + _instrumentPostgRESTFilterBuilderInstance(builder); + } + } catch (error) { + DEBUG_BUILD && debug.warn('Supabase RPC instrumentation setup failed:', error); + } + + return result; + } + + if (isProducerSpan) { + return _instrumentRpcProducer(target, thisArg, argumentsList); + } else if (isConsumerSpan) { + return _instrumentRpcConsumer(target, thisArg, argumentsList); + } + + return Reflect.apply(target, thisArg, argumentsList); + } catch (error) { + DEBUG_BUILD && debug.warn('Supabase queue instrumentation failed:', error); + return Reflect.apply(target, thisArg, argumentsList); + } + }, + }; +} + +/** + * Instruments RPC methods returned from `.schema()` calls. + * This handles the pattern: `client.schema('public').rpc('function_name', params)` + * + * @param SupabaseClient - The Supabase client constructor to instrument + */ +function _instrumentRpcReturnedFromSchemaCall(SupabaseClient: unknown): void { + if (_isInstrumented((SupabaseClient as SupabaseClientConstructorType).prototype.schema)) { return; } - (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema = new Proxy( - (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema, + (SupabaseClient as SupabaseClientConstructorType).prototype.schema = new Proxy( + (SupabaseClient as SupabaseClientConstructorType).prototype.schema, { apply(target, thisArg, argumentsList) { const supabaseInstance = Reflect.apply(target, thisArg, argumentsList); - instrumentRpcMethod(supabaseInstance as unknown as SupabaseClientConstructorType); + _instrumentRpcMethod(supabaseInstance as unknown as SupabaseClientConstructorType); return supabaseInstance; }, }, ); - markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.schema); + _markAsInstrumented((SupabaseClient as SupabaseClientConstructorType).prototype.schema); } -function instrumentRpcMethod(supabaseInstance: SupabaseClientConstructorType): void { - supabaseInstance.rpc = new Proxy((supabaseInstance as unknown as SupabaseClientInstance).rpc, { - apply(target, thisArg, argumentsList) { - const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; - const isConsumerSpan = argumentsList[0] === 'pop'; - if (!isProducerSpan && !isConsumerSpan) { - return Reflect.apply(target, thisArg, argumentsList); - } - if (isProducerSpan) { - return instrumentRpcProducer(target, thisArg, argumentsList); - } else if (isConsumerSpan) { - return instrumentRpcConsumer(target, thisArg, argumentsList); - } - return Reflect.apply(target, thisArg, argumentsList); - }, - }); +/** + * Instruments RPC method on a Supabase instance (typically returned from `.schema()` call). + * Uses the shared proxy handler to route queue operations. + * + * Note: No instrumentation guard here because each `.schema()` call returns a fresh object + * with its own `rpc` method that needs to be instrumented. + * + * @param supabaseInstance - The Supabase instance to instrument + */ +function _instrumentRpcMethod(supabaseInstance: SupabaseClientConstructorType): void { + const instance = supabaseInstance as unknown as SupabaseClientInstance; + + // Only instrument if rpc method exists + if (!instance.rpc) { + return; + } + + instance.rpc = new Proxy(instance.rpc, _createRpcProxyHandler()); } -function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: string; baggage?: string } }): { +/** + * Extracts Sentry trace context from a message's metadata. + * + * @param message - The message object potentially containing _sentry metadata + * @returns Object containing sentryTrace and baggage if present + */ +function _extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: string; baggage?: string } }): { sentryTrace?: string; baggage?: string; } { @@ -278,186 +365,444 @@ function extractTraceAndBaggageFromMessage(message: { _sentry?: { sentry_trace?: } /** - * Instruments the RPC consumer methods of a Supabase client. + * Extracts message IDs from a Supabase queue response. + * Handles single message IDs (numbers), arrays of message IDs, and arrays of message objects. * - * A span is only created when we can match the consumer operation to its corresponding producer span. + * @param data - The response data from a queue operation + * @returns Comma-separated string of message IDs, or undefined if none found + */ +function _extractMessageIds( + data?: + | number + | Array< + | number + | { + [key: string]: unknown; + msg_id?: number; + } + >, +): string | undefined { + // Handle single message ID (e.g., from send RPC) + if (typeof data === 'number') { + return String(data); + } + + if (!Array.isArray(data)) { + return undefined; + } + + const ids = data + .map(item => { + // Handle numeric message IDs in array + if (typeof item === 'number') { + return String(item); + } + // Handle message objects with msg_id field + if (item && typeof item === 'object' && 'msg_id' in item) { + return String(item.msg_id); + } + return null; + }) + .filter(id => id !== null); + + return ids.length > 0 ? ids.join(',') : undefined; +} + +/** + * Creates a breadcrumb for a queue operation. * - * @param target - The original function to instrument. - * @param thisArg - The context to bind the function to. - * @param argumentsList - The arguments to pass to the function. - * @returns A promise that resolves with the result of the original function. + * @param category - The breadcrumb category (e.g., 'queue.publish', 'queue.process') + * @param queueName - The name of the queue + * @param data - Additional data to include in the breadcrumb */ -const instrumentRpcConsumer = (target: any, thisArg: any, argumentsList: any[]): Promise => { - const [operationName, queueParams] = argumentsList as [ - 'pop', - { - queue_name?: string; - }, - ]; +function _createQueueBreadcrumb(category: string, queueName: string | undefined, data?: Record): void { + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category, + message: `${category}(${queueName || 'unknown'})`, + }; - const isConsumerSpan = operationName === 'pop'; - const queueName = queueParams?.queue_name; + if (data && Object.keys(data).length > 0) { + breadcrumb.data = data; + } + + addBreadcrumb(breadcrumb); +} + +/** + * Maximum size for message body size calculation to prevent performance issues. + * Messages larger than this will not have their size calculated. + */ +const MAX_MESSAGE_SIZE_FOR_CALCULATION = 1024 * 100; // 100KB - if (!isConsumerSpan) { - return Reflect.apply(target, thisArg, argumentsList); // Not a consumer operation +/** + * Calculates the size of a message body safely with performance safeguards. + * + * @param message - The message to calculate size for + * @returns The message size in bytes, or undefined if too large or calculation fails + */ +function _calculateMessageBodySize(message: unknown): number | undefined { + if (!message) { + return undefined; } - return startSpan( - { - name: 'supabase.queue.receive', - op: 'queue.process', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - 'messaging.system': 'supabase', - }, - }, - async (span) => { - try { - // Call the original function - const res = await Reflect.apply(target, thisArg, argumentsList) as SupabaseResponse; + try { + const serialized = JSON.stringify(message); + // Only return size if it's under the max limit to avoid performance issues + if (serialized.length <= MAX_MESSAGE_SIZE_FOR_CALCULATION) { + return serialized.length; + } + DEBUG_BUILD && debug.warn('Message body too large for size calculation:', serialized.length); + return undefined; + } catch { + // Ignore JSON stringify errors + return undefined; + } +} + +/** + * Calculates average latency for batch messages. + * + * @param messages - Array of messages with enqueued_at timestamps + * @returns Average latency in milliseconds, or undefined if no valid timestamps + */ +function _calculateBatchLatency(messages: Array<{ enqueued_at?: string }>): number | undefined { + let totalLatency = 0; + let count = 0; + const now = Date.now(); + + for (const msg of messages) { + if (msg.enqueued_at) { + const timestamp = Date.parse(msg.enqueued_at); + if (!Number.isNaN(timestamp)) { + totalLatency += now - timestamp; + count++; + } + } + } - // Calculate latency if possible - const latency = res.data?.[0]?.enqueued_at ? Date.now() - Date.parse(res.data?.[0]?.enqueued_at) : undefined; + return count > 0 ? totalLatency / count : undefined; +} - // Extract trace context - const { sentryTrace, baggage } = extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); +/** + * Processes the consumer span by setting attributes, handling errors, and creating breadcrumbs. + * This is extracted to simplify the instrumentRpcConsumer function. + * + * @param span - The span to process + * @param res - The Supabase response + * @param queueName - The name of the queue + * @returns The original response + */ +function _processConsumerSpan(span: Span, res: SupabaseResponse, queueName: string | undefined): SupabaseResponse { + // Calculate latency for single message or batch average + let latency: number | undefined; + const isBatch = Array.isArray(res.data) && res.data.length > 1; - // Remove Sentry metadata from the returned message - delete res.data?.[0]?.message?._sentry; + if (isBatch) { + latency = _calculateBatchLatency(res.data as Array<{ enqueued_at?: string }>); + } else { + const enqueuedAt = res.data?.[0]?.enqueued_at; + if (enqueuedAt) { + const timestamp = Date.parse(enqueuedAt); + if (!Number.isNaN(timestamp)) { + latency = Date.now() - timestamp; + } else { + DEBUG_BUILD && debug.warn('Invalid enqueued_at timestamp:', enqueuedAt); + } + } + } - // Get message ID if available - const messageId = res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; + // Extract message IDs + const messageId = _extractMessageIds(res.data); - // Set span attributes - if (messageId) { - span.setAttribute('messaging.message.id', messageId); - } + // Set span attributes + if (messageId) { + span.setAttribute('messaging.message.id', messageId); + } - if (queueName) { - span.setAttribute('messaging.destination.name', queueName); - } + // Note: messaging.destination.name is already set in initial span attributes - if (latency) { - span.setAttribute('messaging.message.receive.latency', latency); - } + if (latency !== undefined) { + span.setAttribute('messaging.message.receive.latency', latency); + } - // Add breadcrumb for monitoring - const breadcrumb: SupabaseBreadcrumb = { - type: 'supabase', - category: `db.rpc.${argumentsList[0]}`, - message: `rpc(${argumentsList[0]})`, - }; + // Extract retry count from PGMQ read_ct field + const retryCount = res.data?.[0]?.read_ct ?? 0; + span.setAttribute('messaging.message.retry.count', retryCount); - const data: Record = {}; - if (messageId) data['messaging.message.id'] = messageId; - if (queueName) data['messaging.destination.name'] = queueName; - if (Object.keys(data).length) breadcrumb.data = data; + // Calculate message body size with performance safeguards + const messageBodySize = _calculateMessageBodySize(res.data?.[0]?.message); + if (messageBodySize !== undefined) { + span.setAttribute('messaging.message.body.size', messageBodySize); + } - addBreadcrumb(breadcrumb); + // Add breadcrumb for monitoring + const breadcrumbData: Record = {}; + if (messageId) breadcrumbData['messaging.message.id'] = messageId; + if (queueName) breadcrumbData['messaging.destination.name'] = queueName; + _createQueueBreadcrumb('queue.process', queueName, breadcrumbData); + + // Handle errors in the response + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + if (res.error.code) err.code = res.error.code; + if (res.error.details) err.details = res.error.details; + + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName, messageId }); + return scope; + }); + } - // Handle errors in the response - if (res.error) { - const err = new Error(res.error.message) as SupabaseError; - if (res.error.code) err.code = res.error.code; - if (res.error.details) err.details = res.error.details; + // Set span status based on response + span.setStatus({ code: res.error ? SPAN_STATUS_ERROR : SPAN_STATUS_OK }); - captureException(err, { - contexts: { - supabase: { queueName, messageId }, - }, - }); + return res; +} - span.setStatus({ code: SPAN_STATUS_ERROR }); - } else { - span.setStatus({ code: SPAN_STATUS_OK }); +/** + * Instruments RPC consumer methods for queue message consumption. + * + * Creates queue.process spans and extracts trace context from messages + * for distributed tracing across producer/consumer boundaries. + */ +const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList: unknown[]): Promise => { + if (!Array.isArray(argumentsList) || argumentsList.length < 2) { + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); + } + + if (typeof argumentsList[0] !== 'string') { + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); + } + + const [operationName, queueParams] = argumentsList as [string, unknown]; + + if (!isPlainObject(queueParams)) { + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); + } + + const typedParams = queueParams as { queue_name?: string; vt?: number; qty?: number }; + const queueName = typedParams.queue_name; + + if (!queueName) { + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); + } + + DEBUG_BUILD && + debug.log('Instrumenting Supabase queue consumer', { + operation: operationName, + queueName, + }); + + const spanName = `process ${queueName || 'unknown'}`; + const spanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase.queue.consumer', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + 'messaging.system': 'supabase', + 'messaging.destination.name': queueName, + } as const; + const spanStartTime = timestampInSeconds(); + + const rpcPromise = Reflect.apply( + target as (...args: unknown[]) => Promise, + thisArg, + argumentsList, + ) as Promise; + + return rpcPromise + .then(res => { + DEBUG_BUILD && debug.log('Consumer RPC call completed', { queueName, hasData: !!res.data }); + + const { sentryTrace } = _extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); + + if (Array.isArray(res.data)) { + res.data.forEach(item => { + if (item && typeof item === 'object' && item.message) { + delete item.message._sentry; + } + }); + } + + // Extract producer span context for span link (before creating consumer span) + let producerSpanContext: { traceId: string; spanId: string; traceFlags: number } | undefined; + if (sentryTrace) { + const traceparentData = extractTraceparentData(sentryTrace); + if (traceparentData?.traceId && traceparentData?.parentSpanId) { + // Convert parentSampled boolean to traceFlags (W3C trace context spec) + // traceFlags bit 0 (LSB) = sampled flag: 1 if sampled, 0 if not sampled + const traceFlags = traceparentData.parentSampled ? 1 : 0; + + producerSpanContext = { + traceId: traceparentData.traceId, + spanId: traceparentData.parentSpanId, + traceFlags, + }; } + } - // Continue trace if we have the trace context - if (sentryTrace || baggage) { - return continueTrace( - { sentryTrace, baggage }, - () => startSpan( - { - name: 'supabase.db.rpc', - op: 'queue.process', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - 'messaging.system': 'supabase', - }, - }, - processSpan => { - if (messageId) { - processSpan.setAttribute('messaging.message.id', messageId); - } + const runWithSpan = (): SupabaseResponse => + startSpan( + { + name: spanName, + op: 'queue.process', + startTime: spanStartTime, + attributes: spanAttributes, + // Add span link to producer span for distributed tracing across async queue boundary + links: producerSpanContext + ? [ + { + context: producerSpanContext, + attributes: { 'sentry.link.type': 'queue.producer' }, + }, + ] + : undefined, + }, + span => { + try { + const processedResponse = _processConsumerSpan(span, res, queueName); - if (queueName) { - processSpan.setAttribute('messaging.destination.name', queueName); - } + DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); - if (latency) { - processSpan.setAttribute('messaging.message.receive.latency', latency); - } + return processedResponse; + } catch (err: unknown) { + DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); - if (res.error) { - processSpan.setStatus({ code: SPAN_STATUS_ERROR }); - } else { - processSpan.setStatus({ code: SPAN_STATUS_OK }); - } + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName }); + return scope; + }); - processSpan.end(); - return res; - } - ) + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw err; + } + }, + ); + + // Create consumer span with link to producer (not as a child) + return runWithSpan(); + }) + .catch((err: unknown) => { + DEBUG_BUILD && debug.log('Consumer RPC call failed', { queueName, error: err }); + + return startSpan( + { + name: spanName, + op: 'queue.process', + startTime: spanStartTime, + attributes: spanAttributes, + }, + span => { + if (queueName) { + span.setAttribute('messaging.destination.name', queueName); + } + + const breadcrumbData: Record = {}; + if (queueName) { + breadcrumbData['messaging.destination.name'] = queueName; + } + _createQueueBreadcrumb( + 'queue.process', + queueName, + Object.keys(breadcrumbData).length ? breadcrumbData : undefined, ); - } - return res; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR }); - throw error; - } finally { - span.end(); - } - } - ); + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName }); + return scope; + }); + + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw err; + }, + ); + }); }; /** - * Instruments the RPC producer methods of a Supabase client. + * Instruments RPC producer methods for queue message production. * - * @param target - The original function to instrument. - * @param thisArg - The context to bind the function to. - * @param argumentsList - The arguments to pass to the function. - * @returns A promise that resolves with the result of the original function. + * Creates queue.publish spans and injects trace context into messages + * for distributed tracing across producer/consumer boundaries. */ -function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): Promise { +function _instrumentRpcProducer(target: unknown, thisArg: unknown, argumentsList: unknown[]): Promise { + // Runtime validation + if (!Array.isArray(argumentsList) || argumentsList.length < 2) { + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); + } + const maybeQueueParams = argumentsList[1]; // If the second argument is not an object, it's not a queue operation if (!isPlainObject(maybeQueueParams)) { - return Reflect.apply(target, thisArg, argumentsList); + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); } - const queueName = maybeQueueParams?.queue_name as string; + // Now safe to type assert + const queueParams = maybeQueueParams as { queue_name?: string; message?: unknown; messages?: unknown[] }; + const queueName = queueParams?.queue_name; - // If the queue name is not provided, return the original function if (!queueName) { - return Reflect.apply(target, thisArg, argumentsList); + return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); } + const operationName = argumentsList[0] as 'send' | 'send_batch'; + const isBatch = operationName === 'send_batch'; + + DEBUG_BUILD && + debug.log('Instrumenting Supabase queue producer', { + operation: operationName, + queueName, + isBatch, + }); + + // Calculate message body size upfront for initial span attributes + const messageBodySize = _calculateMessageBodySize(queueParams?.message || queueParams?.messages); + return startSpan( { - name: 'supabase.db.rpc', + name: `publish ${queueName || 'unknown'}`, + op: 'queue.publish', attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase.queue.producer', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish', 'messaging.system': 'supabase', + 'messaging.destination.name': queueName, + ...(messageBodySize !== undefined && { 'messaging.message.body.size': messageBodySize }), }, }, span => { - const { 'sentry-trace': sentryTrace, baggage: sentryBaggage } = getTraceData(); + const sentryTrace = spanToTraceHeader(span); + const scope = getCurrentScope(); + const client = getClient(); + const { dsc } = scope.getPropagationContext(); + const traceContext = spanToTraceContext(span); + const sentryBaggage = dynamicSamplingContextToSentryBaggageHeader( + dsc || + (client ? getDynamicSamplingContextFromClient(traceContext.trace_id, client) : undefined) || + getDynamicSamplingContextFromSpan(span), + ); + const [, sentryArgumentsQueueParams] = argumentsList as [ 'send' | 'send_batch', { @@ -467,72 +812,79 @@ function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): }, ]; + // Inject trace context into messages (avoid mutation) if (sentryArgumentsQueueParams?.message) { - sentryArgumentsQueueParams.message._sentry = { - sentry_trace: sentryTrace, - baggage: sentryBaggage, + sentryArgumentsQueueParams.message = { + ...sentryArgumentsQueueParams.message, + _sentry: { + sentry_trace: sentryTrace, + baggage: sentryBaggage, + }, }; } else if (sentryArgumentsQueueParams?.messages) { - sentryArgumentsQueueParams.messages = sentryArgumentsQueueParams.messages.map(message => { - message._sentry = { + sentryArgumentsQueueParams.messages = sentryArgumentsQueueParams.messages.map(message => ({ + ...message, + _sentry: { sentry_trace: sentryTrace, baggage: sentryBaggage, - }; - return message; - }); + }, + })); } argumentsList[1] = sentryArgumentsQueueParams; - return (Reflect.apply(target, thisArg, argumentsList) as Promise) + const promise = Reflect.apply( + target as (...args: unknown[]) => Promise, + thisArg, + argumentsList, + ) as Promise; + return promise .then((res: SupabaseResponse) => { - const messageId = - res?.data?.map(item => (typeof item === 'number' ? item : item.msg_id)).join(',') || undefined; + const messageId = _extractMessageIds(res.data); + // messaging.message.id is set after response since PGMQ generates it if (messageId) { - span.setAttribute('messaging.message.id', messageId || ''); + span.setAttribute('messaging.message.id', messageId); } - if (queueName) { - span.setAttribute('messaging.destination.name', queueName || ''); + // messaging.batch.message_count is set for batch operations + if (isBatch && Array.isArray(res?.data)) { + span.setAttribute('messaging.batch.message_count', res.data.length); } - const breadcrumb: SupabaseBreadcrumb = { - type: 'supabase', - category: `db.rpc.${argumentsList[0]}`, - message: `rpc(${argumentsList[0]})`, + const breadcrumbData: Record = { + 'messaging.destination.name': queueName, }; - const data: Record = {}; if (messageId) { - data['messaging.message.id'] = messageId; + breadcrumbData['messaging.message.id'] = messageId; } - if (queueName) { - data['messaging.destination.name'] = queueName; + if (messageBodySize !== undefined) { + breadcrumbData['messaging.message.body.size'] = messageBodySize; } - if (Object.keys(data).length) { - breadcrumb.data = data; + if (isBatch && Array.isArray(res?.data)) { + breadcrumbData['messaging.batch.message_count'] = res.data.length; } - addBreadcrumb(breadcrumb); + _createQueueBreadcrumb('queue.publish', queueName, breadcrumbData); + if (res.error) { const err = new Error(res.error.message) as SupabaseError; - if (res.error.code) { - err.code = res.error.code; - } - if (res.error.details) { - err.details = res.error.details; - } - captureException(err, { - contexts: { - supabase: { - queueName, - messageId, - }, - }, + if (res.error.code) err.code = res.error.code; + if (res.error.details) err.details = res.error.details; + + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName, messageId, operation: operationName }); + return scope; }); - span.setStatus({ code: SPAN_STATUS_ERROR }); - } else { - span.setStatus({ code: SPAN_STATUS_OK }); } + + span.setStatus({ code: res.error ? SPAN_STATUS_ERROR : SPAN_STATUS_OK }); span.end(); return res; @@ -540,11 +892,19 @@ function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): .catch((err: unknown) => { span.setStatus({ code: SPAN_STATUS_ERROR }); span.end(); - captureException(err, { - mechanism: { - handled: false, - }, + + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName, operation: operationName }); + return scope; }); + throw err; }); }, @@ -552,33 +912,29 @@ function instrumentRpcProducer(target: any, thisArg: any, argumentsList: any[]): } /** - * Instruments the RPC methods of a Supabase client. + * Instruments direct RPC calls on a Supabase client. + * This handles the pattern: `client.rpc('function_name', params)` + * Uses the shared proxy handler to route queue operations. * - * @param SupabaseClient - The Supabase client instance to instrument. + * @param SupabaseClient - The Supabase client instance to instrument */ -function instrumentRpc(SupabaseClient: unknown): void { - (SupabaseClient as unknown as SupabaseClientInstance).rpc = new Proxy( - (SupabaseClient as unknown as SupabaseClientInstance).rpc, - { - apply(target, thisArg, argumentsList) { - let result: Promise; - - // Check if the first argument is 'send', 'send_batch', or 'pop' to determine if it's a producer or consumer operation - if (argumentsList[0] === 'send' || argumentsList[0] === 'send_batch') { - result = instrumentRpcProducer(target, thisArg, argumentsList); - } else if (argumentsList[0] === 'pop') { - result = instrumentRpcConsumer(target, thisArg, argumentsList); - } else { - result = Reflect.apply(target, thisArg, argumentsList) as Promise; - } +function _instrumentRpc(SupabaseClient: unknown): void { + const client = SupabaseClient as SupabaseClientInstance; - return result; - }, - }, - ); + if (!client.rpc) { + return; + } + + client.rpc = new Proxy(client.rpc, _createRpcProxyHandler()); } -function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn { +/** + * Instruments Supabase auth operations. + * + * Creates auto.db.supabase spans for auth operations (signIn, signUp, etc.) + * to track authentication performance and errors. + */ +function _instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn { return new Proxy(operation, { apply(target, thisArg, argumentsList) { return startSpan( @@ -622,18 +978,26 @@ function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): A }); throw err; - }) - .then(...argumentsList); + }); }, ); }, }); } -function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInstance): void { +/** + * Instruments all auth operations on a Supabase client instance. + * + * Iterates through AUTH_OPERATIONS_TO_INSTRUMENT and AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT, + * wrapping each operation with Sentry instrumentation. Handles both regular auth operations + * (signIn, signUp, etc.) and admin operations (createUser, deleteUser, etc.). + * + * @param supabaseClientInstance - The Supabase client instance to instrument + */ +function _instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInstance): void { const auth = supabaseClientInstance.auth; - if (!auth || isInstrumented(supabaseClientInstance.auth)) { + if (!auth || _isInstrumented(supabaseClientInstance.auth)) { return; } @@ -645,7 +1009,7 @@ function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInst } if (typeof supabaseClientInstance.auth[operation] === 'function') { - supabaseClientInstance.auth[operation] = instrumentAuthOperation(authOperation); + supabaseClientInstance.auth[operation] = _instrumentAuthOperation(authOperation); } } @@ -657,223 +1021,311 @@ function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInst } if (typeof supabaseClientInstance.auth.admin[operation] === 'function') { - supabaseClientInstance.auth.admin[operation] = instrumentAuthOperation(authOperation, true); + supabaseClientInstance.auth.admin[operation] = _instrumentAuthOperation(authOperation, true); } } - markAsInstrumented(supabaseClientInstance.auth); + _markAsInstrumented(supabaseClientInstance.auth); } -function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void { - if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from)) { +function _instrumentSupabaseClientConstructor(SupabaseClient: unknown): void { + if (_isInstrumented((SupabaseClient as SupabaseClientConstructorType).prototype.from)) { return; } - (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from = new Proxy( - (SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from, + (SupabaseClient as SupabaseClientConstructorType).prototype.from = new Proxy( + (SupabaseClient as SupabaseClientConstructorType).prototype.from, { apply(target, thisArg, argumentsList) { const rv = Reflect.apply(target, thisArg, argumentsList); const PostgRESTQueryBuilder = (rv as PostgRESTQueryBuilder).constructor; - instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder); + _instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder); return rv; }, }, ); - markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructorType).prototype.from); + _markAsInstrumented((SupabaseClient as SupabaseClientConstructorType).prototype.from); } -function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void { - if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) { - return; - } - - (PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then = new Proxy( - (PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then, - { - apply(target, thisArg, argumentsList) { - const operations = DB_OPERATIONS_TO_INSTRUMENT; - const typedThis = thisArg as PostgRESTFilterBuilder; - const operation = extractOperation(typedThis.method, typedThis.headers); +/** + * Instruments PostgREST filter builder to trace database operations. + * + * This function intercepts the `.then()` method on PostgRESTFilterBuilder to wrap + * database operations with Sentry tracing. It extracts operation details (table name, + * query parameters, body) and creates spans with appropriate semantic attributes. + * + * The instrumentation pattern: + * 1. Intercepts user's `.then(callback)` call + * 2. Calls original `.then()` with no arguments to get the raw promise + * 3. Adds instrumentation callbacks to create spans and capture errors + * 4. Forwards user's callbacks to receive the instrumented result + * + * This ensures the user's callbacks receive the result AFTER instrumentation completes. + * + * @param PostgRESTFilterBuilder - The PostgREST filter builder constructor to instrument + */ +function _createInstrumentedPostgRESTThen( + originalThen: PostgRESTProtoThenable['then'], +): PostgRESTProtoThenable['then'] { + return new Proxy(originalThen, { + apply(target, thisArg, argumentsList) { + const operations = DB_OPERATIONS_TO_INSTRUMENT; + const typedThis = thisArg as PostgRESTFilterBuilder; + const operation = extractOperation(typedThis.method, typedThis.headers); - if (!operations.includes(operation)) { - return Reflect.apply(target, thisArg, argumentsList); - } + if (!operations.includes(operation)) { + return Reflect.apply(target, thisArg, argumentsList); + } - if (!typedThis?.url?.pathname || typeof typedThis.url.pathname !== 'string') { - return Reflect.apply(target, thisArg, argumentsList); - } + if (!typedThis?.url?.pathname || typeof typedThis.url.pathname !== 'string') { + return Reflect.apply(target, thisArg, argumentsList); + } - const pathParts = typedThis.url.pathname.split('/'); + const pathParts = typedThis.url.pathname.split('/'); + const rpcIndex = pathParts.indexOf('rpc'); + const rpcFunctionName = rpcIndex !== -1 && pathParts.length > rpcIndex + 1 ? pathParts[rpcIndex + 1] : undefined; - if (pathParts.includes('rpc')) { - // RPC calls are instrumented in the `instrumentRpc` function - // and should not be instrumented here. - return Reflect.apply(target, thisArg, argumentsList); - } + if (rpcFunctionName && QUEUE_RPC_OPERATIONS.has(rpcFunctionName)) { + // Queue RPC calls are instrumented in the dedicated queue instrumentation. + return Reflect.apply(target, thisArg, argumentsList); + } - const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : ''; + const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : ''; - const queryItems: string[] = []; - for (const [key, value] of typedThis.url.searchParams.entries()) { - // It's possible to have multiple entries for the same key, eg. `id=eq.7&id=eq.3`, - // so we need to use array instead of object to collect them. - queryItems.push(translateFiltersIntoMethods(key, value)); - } - const body: Record = Object.create(null); - if (isPlainObject(typedThis.body)) { - for (const [key, value] of Object.entries(typedThis.body)) { - body[key] = value; - } + const queryItems: string[] = []; + for (const [key, value] of typedThis.url.searchParams.entries()) { + // It's possible to have multiple entries for the same key, eg. `id=eq.7&id=eq.3`, + // so we need to use array instead of object to collect them. + queryItems.push(translateFiltersIntoMethods(key, value)); + } + const body: Record = Object.create(null); + if (isPlainObject(typedThis.body)) { + for (const [key, value] of Object.entries(typedThis.body)) { + body[key] = value; } + } - // Adding operation to the beginning of the description if it's not a `select` operation - // For example, it can be an `insert` or `update` operation but the query can be `select(...)` - // For `select` operations, we don't need repeat it in the description - const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join( - ' ', - )} from(${table})`; - - const attributes: Record = { - 'db.table': table, - 'db.schema': typedThis.schema, - 'db.url': typedThis.url.origin, - 'db.sdk': typedThis.headers['X-Client-Info'], - 'db.system': 'postgresql', - 'db.operation': operation, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - }; + // Adding operation to the beginning of the description if it's not a `select` operation + // For example, it can be an `insert` or `update` operation but the query can be `select(...)` + // For `select` operations, we don't need repeat it in the description + const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join( + ' ', + )} from(${table})`; + + const attributes: Record = { + 'db.table': table, + 'db.schema': typedThis.schema, + 'db.url': typedThis.url.origin, + 'db.sdk': typedThis.headers['X-Client-Info'], + 'db.system': 'postgresql', + 'db.operation': operation, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + }; - if (queryItems.length) { - attributes['db.query'] = queryItems; - } + if (queryItems.length) { + attributes['db.query'] = queryItems; + } - if (Object.keys(body).length) { - attributes['db.body'] = body; - } + if (Object.keys(body).length) { + attributes['db.body'] = body; + } - return startSpan( - { - name: description, - attributes, - }, - span => { - return (Reflect.apply(target, thisArg, []) as Promise) - .then( - (res: SupabaseResponse) => { - if (span) { - if (res && typeof res === 'object' && 'status' in res) { - setHttpStatus(span, res.status || 500); - } - span.end(); + return startSpan( + { + name: description, + attributes: attributes as SpanAttributes, + }, + span => { + return (Reflect.apply(target, thisArg, []) as Promise) + .then( + (res: SupabaseResponse) => { + if (span) { + if (res && typeof res === 'object' && 'status' in res) { + setHttpStatus(span, res.status || 500); } + span.end(); + } - const breadcrumb: SupabaseBreadcrumb = { - type: 'supabase', - category: `db.${operation}`, - message: description, - }; + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.${operation}`, + message: description, + }; - const data: Record = {}; + const data: Record = {}; + if (queryItems.length) { + data.query = queryItems; + } + + if (Object.keys(body).length) { + data.body = body; + } + + if (Object.keys(data).length) { + breadcrumb.data = data; + } + + addBreadcrumb(breadcrumb); + + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + if (res.error.code) err.code = res.error.code; + if (res.error.details) err.details = res.error.details; + + const supabaseContext: Record = {}; if (queryItems.length) { - data.query = queryItems; + supabaseContext.query = queryItems; } - if (Object.keys(body).length) { - data.body = body; - } - - if (Object.keys(data).length) { - breadcrumb.data = data; + supabaseContext.body = body; } - addBreadcrumb(breadcrumb); - - if (res.error) { - const err = new Error(res.error.message) as SupabaseError; - if (res.error.code) { - err.code = res.error.code; - } - if (res.error.details) { - err.details = res.error.details; - } - - const supabaseContext: Record = {}; - if (queryItems.length) { - supabaseContext.query = queryItems; - } - if (Object.keys(body).length) { - supabaseContext.body = body; - } - - captureException(err, scope => { - scope.addEventProcessor(e => { - addExceptionMechanism(e, { - handled: false, - type: 'auto.db.supabase.postgres', - }); - - return e; + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.postgres', }); - scope.setContext('supabase', supabaseContext); + return e; + }); + + scope.setContext('supabase', supabaseContext); - return scope; + return scope; + }); + } + + return res; + }, + (err: Error) => { + // Capture exception for database operation errors (network failures, etc.) + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.postgres', }); - } + return e; + }); + scope.setContext('supabase', { + operation: operation, + table: table, + }); + return scope; + }); - return res; - }, - (err: Error) => { - // TODO: shouldn't we capture this error? - if (span) { - setHttpStatus(span, 500); - span.end(); - } - throw err; - }, - ) - .then(...argumentsList); - }, - ); - }, + if (span) { + setHttpStatus(span, 500); + span.end(); + } + throw err; + }, + ) + .then(...argumentsList); + }, + ); }, - ); + }); +} + +function _instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void { + const prototype = PostgRESTFilterBuilder?.prototype as unknown as PostgRESTProtoThenable | undefined; + + if (!prototype) { + return; + } + + const originalThen = prototype.then; + + if (typeof originalThen !== 'function') { + return; + } + + if (_isInstrumented(originalThen)) { + return; + } + + prototype.then = _createInstrumentedPostgRESTThen(originalThen); + + _markAsInstrumented(prototype.then); +} + +function _instrumentPostgRESTFilterBuilderInstance(builder: PostgRESTFilterBuilder): void { + if (!builder || typeof builder !== 'object') { + return; + } + + const thenable = builder as unknown as PostgRESTProtoThenable; + const originalThen = thenable?.then; + + if (typeof originalThen !== 'function') { + return; + } + + if (_isInstrumented(originalThen)) { + return; + } + + thenable.then = _createInstrumentedPostgRESTThen(originalThen); - markAsInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then); + _markAsInstrumented(thenable.then); } -function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgRESTQueryBuilder): void { +/** + * Instruments PostgREST query builder operations (select, insert, update, delete, upsert). + * + * This function wraps each database operation method on PostgRESTQueryBuilder. When an operation + * is called, it returns a PostgRESTFilterBuilder, which is then instrumented to trace the actual + * database call. + * + * We instrument all operations (despite them sharing the same PostgRESTFilterBuilder constructor) + * because we don't know which operation will be called first, and we want to ensure no calls + * are missed. + * + * @param PostgRESTQueryBuilder - The PostgREST query builder constructor to instrument + */ +function _instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgRESTQueryBuilder): void { // We need to wrap _all_ operations despite them sharing the same `PostgRESTFilterBuilder` // constructor, as we don't know which method will be called first, and we don't want to miss any calls. for (const operation of DB_OPERATIONS_TO_INSTRUMENT) { - if (isInstrumented((PostgRESTQueryBuilder.prototype as Record)[operation])) { + type PostgRESTOperation = keyof Pick; + const prototypeWithOps = PostgRESTQueryBuilder.prototype as Partial< + Record + >; + + const originalOperation = prototypeWithOps[operation as PostgRESTOperation]; + + if (_isInstrumented(originalOperation)) { continue; } - type PostgRESTOperation = keyof Pick; - (PostgRESTQueryBuilder.prototype as Record)[operation as PostgRESTOperation] = new Proxy( - (PostgRESTQueryBuilder.prototype as Record)[operation as PostgRESTOperation], - { - apply(target, thisArg, argumentsList) { - const rv = Reflect.apply(target, thisArg, argumentsList); - const PostgRESTFilterBuilder = (rv as PostgRESTFilterBuilder).constructor; + if (!originalOperation) { + continue; + } - DEBUG_BUILD && debug.log(`Instrumenting ${operation} operation's PostgRESTFilterBuilder`); + const wrappedOperation = new Proxy(originalOperation, { + apply(target: PostgRESTQueryOperationFn, thisArg: unknown, argumentsList: Parameters) { + const rv = Reflect.apply(target, thisArg, argumentsList); + const PostgRESTFilterBuilder = rv.constructor; - instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder); + DEBUG_BUILD && debug.log(`Instrumenting ${operation} operation's PostgRESTFilterBuilder`); - return rv; - }, + _instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder); + _instrumentPostgRESTFilterBuilderInstance(rv); + + return rv; }, - ); + }); - markAsInstrumented((PostgRESTQueryBuilder.prototype as Record)[operation]); + prototypeWithOps[operation as PostgRESTOperation] = wrappedOperation; + + _markAsInstrumented(wrappedOperation); } } @@ -885,23 +1337,39 @@ export const instrumentSupabaseClient = (supabaseClient: unknown): void => { const SupabaseClientConstructor = supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor; - instrumentSupabaseClientConstructor(SupabaseClientConstructor); - instrumentRpcReturnedFromSchemaCall(SupabaseClientConstructor); - instrumentRpc(supabaseClient as SupabaseClientInstance); - instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance); + _instrumentSupabaseClientConstructor(SupabaseClientConstructor); + _instrumentRpcReturnedFromSchemaCall(SupabaseClientConstructor); + _instrumentRpc(supabaseClient as SupabaseClientInstance); + _instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance); }; -const INTEGRATION_NAME = 'Supabase'; - -const _supabaseIntegration = ((supabaseClient: unknown) => { +const _supabaseIntegration = ((options: { supabaseClient: unknown }) => { return { + name: INTEGRATION_NAME, setupOnce() { - instrumentSupabaseClient(supabaseClient); + instrumentSupabaseClient(options.supabaseClient); }, - name: INTEGRATION_NAME, }; }) satisfies IntegrationFn; -export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => { - return _supabaseIntegration(options.supabaseClient); -}) satisfies IntegrationFn; +/** + * Adds Sentry tracing instrumentation for the [Supabase](https://supabase.com/) library. + * + * Instruments Supabase client operations including database queries, auth operations, and queue operations (via PGMQ). + * Creates spans and breadcrumbs for all operations, with support for distributed tracing across queue producers and consumers. + * + * For more information, see the [`supabaseIntegration` documentation](https://docs.sentry.io/platforms/javascript/configuration/integrations/supabase/). + * + * @example + * ```javascript + * const Sentry = require('@sentry/core'); + * const { createClient } = require('@supabase/supabase-js'); + * + * const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + * + * Sentry.init({ + * integrations: [Sentry.supabaseIntegration({ supabaseClient: supabase })], + * }); + * ``` + */ +export const supabaseIntegration = defineIntegration(_supabaseIntegration); diff --git a/packages/core/test/lib/integrations/supabase-queues.test.ts b/packages/core/test/lib/integrations/supabase-queues.test.ts new file mode 100644 index 000000000000..42d87daac1c1 --- /dev/null +++ b/packages/core/test/lib/integrations/supabase-queues.test.ts @@ -0,0 +1,1070 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client } from '../../../src'; +import * as Breadcrumbs from '../../../src/breadcrumbs'; +import * as CurrentScopes from '../../../src/currentScopes'; +import type { SupabaseClientInstance, SupabaseResponse } from '../../../src/integrations/supabase'; +import { instrumentSupabaseClient } from '../../../src/integrations/supabase'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../../src/semanticAttributes'; +import * as Tracing from '../../../src/tracing'; +import { startSpan } from '../../../src/tracing'; + +describe('Supabase Queue Instrumentation', () => { + let mockClient: Client; + let mockRpcFunction: any; + let mockSupabaseClient: SupabaseClientInstance; + + beforeEach(() => { + mockClient = { + getOptions: () => ({ + normalizeDepth: 3, + normalizeMaxBreadth: 1000, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }), + getDsn: () => ({ + protocol: 'https', + publicKey: 'public', + pass: '', + host: 'dsn.ingest.sentry.io', + port: '', + path: '', + projectId: '1337', + }), + getIntegrationByName: () => undefined, + on: vi.fn(), + emit: vi.fn(), + getTransport: () => ({ send: vi.fn() }), + } as unknown as Client; + + vi.spyOn(CurrentScopes, 'getClient').mockImplementation(() => mockClient); + + // Create a mock RPC function + mockRpcFunction = vi.fn(); + + // Create a mock Supabase client with proper structure + mockSupabaseClient = { + constructor: function SupabaseClient() { + // Constructor mock + }, + rpc: mockRpcFunction, + auth: { + signInWithPassword: vi.fn(), + admin: { + createUser: vi.fn(), + }, + }, + } as unknown as SupabaseClientInstance; + + // Add prototype methods for from() to support database instrumentation + (mockSupabaseClient.constructor as any).prototype = { + from: vi.fn(), + schema: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Producer Spans (send)', () => { + it('should create a queue.publish span for single message send', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + expect(mockRpcFunction).toHaveBeenCalledWith('send', { + queue_name: 'test-queue', + message: expect.objectContaining({ + foo: 'bar', + _sentry: expect.objectContaining({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }), + }), + }); + }); + + it('should create a queue.publish span for batch message send', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }, { msg_id: 124 }, { msg_id: 125 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send_batch', { + queue_name: 'test-queue', + messages: [{ foo: 'bar' }, { baz: 'qux' }], + }); + }); + + expect(mockRpcFunction).toHaveBeenCalledWith('send_batch', { + queue_name: 'test-queue', + messages: expect.arrayContaining([ + expect.objectContaining({ + foo: 'bar', + _sentry: expect.objectContaining({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }), + }), + expect.objectContaining({ + baz: 'qux', + _sentry: expect.objectContaining({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }), + }), + ]), + }); + }); + + it('should inject trace context into message metadata', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message._sentry).toEqual({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }); + }); + + it('should handle producer errors and capture exception', async () => { + const mockError = new Error('Queue send failed'); + mockRpcFunction.mockRejectedValue(mockError); + instrumentSupabaseClient(mockSupabaseClient); + + await expect( + startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }), + ).rejects.toThrow('Queue send failed'); + }); + + it('should handle response errors in producer', async () => { + const mockResponse: SupabaseResponse = { + data: [], + error: { + message: 'Queue is full', + code: 'QUEUE_FULL', + }, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + expect(mockRpcFunction).toHaveBeenCalled(); + + // Verify producer span was created despite error response + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.publish'); + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]?.name).toBe('publish test-queue'); + }); + }); + + describe('Consumer Spans (pop)', () => { + it('should create a queue.process span for message consumption', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { foo: 'bar' }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + const result = await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + + expect(result).toEqual(mockResponse); + }); + + expect(mockRpcFunction).toHaveBeenCalledWith('pop', { + queue_name: 'test-queue', + }); + }); + + it('should extract and clean up trace context from message', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { + sentry_trace: '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + })) as SupabaseResponse; + + // Verify _sentry metadata was removed from the response + expect(result.data?.[0]?.message).not.toHaveProperty('_sentry'); + // Verify other message data is intact + expect(result.data?.[0]?.message).toEqual({ foo: 'bar' }); + }); + }); + + it('should extract trace context and create consumer span when message contains trace context', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + message: { + data: 'test', + _sentry: { + sentry_trace: '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + const result = await mockSupabaseClient.rpc('pop', { + queue_name: 'trace-test-queue', + }); + + // Verify consumer span was created (implementation uses span links for distributed tracing) + expect(startSpanSpy).toHaveBeenCalled(); + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + expect(consumerSpanCall?.[0]?.name).toBe('process trace-test-queue'); + + // Verify _sentry metadata was removed from the response + expect((result as SupabaseResponse).data?.[0]?.message).toEqual({ data: 'test' }); + expect((result as SupabaseResponse).data?.[0]?.message).not.toHaveProperty('_sentry'); + }); + + it('should remove _sentry metadata from consumed messages', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { + sentry_trace: 'test-trace', + baggage: 'test-baggage', + }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + })) as SupabaseResponse; + + expect(result.data?.[0]?.message).toEqual({ foo: 'bar' }); + expect(result.data?.[0]?.message).not.toHaveProperty('_sentry'); + }); + + it('should create consumer span when no trace context in message', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { foo: 'bar' }, // No _sentry field + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + // Spy on startSpanManual + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + startSpanSpy.mockClear(); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + + // Verify startSpan was called (consumer span created) + expect(startSpanSpy).toHaveBeenCalled(); + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + }); + + it('should handle consumer errors and capture exception', async () => { + const mockResponse: SupabaseResponse = { + data: [], + error: { + message: 'Queue not found', + code: 'QUEUE_NOT_FOUND', + }, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + }); + + expect(mockRpcFunction).toHaveBeenCalled(); + + // Verify consumer span was created despite error response + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + expect(processSpanCall?.[0]?.name).toBe('process test-queue'); + }); + + it('should handle multiple messages in consumer response', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { foo: 'bar', _sentry: { sentry_trace: 'trace1', baggage: 'bag1' } }, + }, + { + msg_id: 124, + message: { baz: 'qux', _sentry: { sentry_trace: 'trace2', baggage: 'bag2' } }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + })) as SupabaseResponse; + + // Verify all _sentry metadata was removed + expect(result.data?.[0]?.message).not.toHaveProperty('_sentry'); + expect(result.data?.[1]?.message).not.toHaveProperty('_sentry'); + }); + + it('should create span link to producer span when trace context is present', async () => { + const producerTraceId = 'a'.repeat(32); + const producerSpanId = 'b'.repeat(16); + const sentryTrace = `${producerTraceId}-${producerSpanId}-1`; + + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { + sentry_trace: sentryTrace, + baggage: 'sentry-environment=production', + }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + + // Verify startSpan was called with span link + expect(startSpanSpy).toHaveBeenCalled(); + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + + // Verify span link was created pointing to producer span + const spanOptions = consumerSpanCall?.[0]; + expect(spanOptions?.links).toBeDefined(); + expect(spanOptions?.links?.length).toBeGreaterThanOrEqual(1); + + const producerLink = spanOptions?.links?.[0]; + expect(producerLink).toMatchObject({ + context: { + traceId: producerTraceId, + spanId: producerSpanId, + traceFlags: 1, // sampled=true + }, + attributes: { + 'sentry.link.type': 'queue.producer', + }, + }); + }); + + it('should not create span link when no trace context in message', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { foo: 'bar' }, // No _sentry field + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + + // Verify startSpan was called + expect(startSpanSpy).toHaveBeenCalled(); + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + + // Verify no span link was created + const spanOptions = consumerSpanCall?.[0]; + expect(spanOptions?.links).toBeUndefined(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty response data array', async () => { + const mockResponse: SupabaseResponse = { + data: [], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + const result = await mockSupabaseClient.rpc('pop', { + queue_name: 'empty-queue', + }); + + expect(result).toEqual(mockResponse); + expect(mockRpcFunction).toHaveBeenCalledWith('pop', { + queue_name: 'empty-queue', + }); + + // Verify consumer span was still created + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + }); + + it('should handle malformed _sentry metadata gracefully', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { + sentry_trace: 'invalid-trace-format', // Invalid trace format + baggage: '', // Empty baggage + }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'malformed-queue', + })) as SupabaseResponse; + + // Should still remove _sentry metadata even if malformed + expect(result.data?.[0]?.message).toEqual({ foo: 'bar' }); + expect(result.data?.[0]?.message).not.toHaveProperty('_sentry'); + }); + + it('should handle batch consumer with mixed _sentry metadata', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 1, + message: { + data: 'first', + _sentry: { sentry_trace: 'trace1', baggage: 'bag1' }, + }, + }, + { + msg_id: 2, + message: { + data: 'second', + // No _sentry metadata + }, + }, + { + msg_id: 3, + message: { + data: 'third', + _sentry: { sentry_trace: 'trace3', baggage: 'bag3' }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = await mockSupabaseClient.rpc('pop', { + queue_name: 'mixed-queue', + }); + + // Verify all messages are cleaned up appropriately + expect((result as SupabaseResponse).data?.[0]?.message).toEqual({ data: 'first' }); + expect((result as SupabaseResponse).data?.[0]?.message).not.toHaveProperty('_sentry'); + + expect((result as SupabaseResponse).data?.[1]?.message).toEqual({ data: 'second' }); + expect((result as SupabaseResponse).data?.[1]?.message).not.toHaveProperty('_sentry'); + + expect((result as SupabaseResponse).data?.[2]?.message).toEqual({ data: 'third' }); + expect((result as SupabaseResponse).data?.[2]?.message).not.toHaveProperty('_sentry'); + }); + + it('should extract retry count from read_ct field', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + read_ct: 3, // Retry count field + message: { foo: 'bar' }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + // Should extract and set retry count from PGMQ read_ct field + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'retry-queue', + })) as SupabaseResponse; + + // Verify the response was processed successfully + expect(result.data).toBeDefined(); + expect(result.data?.[0]?.msg_id).toBe(456); + expect(result.data?.[0]?.read_ct).toBe(3); + + // Full span attribute verification is done in E2E tests + expect(mockRpcFunction).toHaveBeenCalledWith('pop', { + queue_name: 'retry-queue', + }); + }); + }); + + describe('Non-Queue RPC Operations', () => { + it('should not instrument non-queue RPC calls', async () => { + const mockResponse = { data: { result: 'success' } }; + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = await mockSupabaseClient.rpc('custom_function', { + param: 'value', + }); + + expect(result).toEqual(mockResponse); + expect(mockRpcFunction).toHaveBeenCalledWith('custom_function', { + param: 'value', + }); + }); + + it('should pass through RPC calls without queue_name parameter', async () => { + const mockResponse = { data: { result: 'success' } }; + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const result = await mockSupabaseClient.rpc('send', { + other_param: 'value', + }); + + expect(result).toEqual(mockResponse); + expect(mockRpcFunction).toHaveBeenCalledWith('send', { + other_param: 'value', + }); + }); + }); + + describe('Trace Propagation', () => { + it('should propagate trace from producer to consumer', async () => { + let capturedTraceContext: { sentry_trace?: string; baggage?: string } | undefined; + + // Producer: send message + const produceResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockImplementation(async (operation: string, params: any) => { + if (operation === 'send') { + capturedTraceContext = params.message._sentry; + return produceResponse; + } + // Consumer: return message with trace context + return { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: capturedTraceContext, + }, + }, + ], + status: 200, + }; + }); + + instrumentSupabaseClient(mockSupabaseClient); + + // Producer span + await startSpan({ name: 'producer-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + expect(capturedTraceContext).toBeDefined(); + expect(capturedTraceContext?.sentry_trace).toBeTruthy(); + expect(capturedTraceContext?.baggage).toBeTruthy(); + + // Consumer span + await startSpan({ name: 'consumer-transaction' }, async () => { + const result = (await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + })) as SupabaseResponse; + + // Verify metadata was removed + expect(result.data?.[0]?.message).not.toHaveProperty('_sentry'); + }); + }); + }); + + describe('Message ID Extraction', () => { + it('should extract message IDs from response data', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }, { msg_id: 456 }, { msg_id: 789 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send_batch', { + queue_name: 'test-queue', + messages: [{ a: 1 }, { b: 2 }, { c: 3 }], + }); + }); + + expect(mockRpcFunction).toHaveBeenCalled(); + }); + + it('should handle missing message IDs gracefully', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: undefined, message: {} }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const addBreadcrumbSpy = vi.spyOn(Breadcrumbs, 'addBreadcrumb'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + expect(mockRpcFunction).toHaveBeenCalled(); + + // Verify breadcrumb was created even without message ID + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'queue.publish', + data: expect.objectContaining({ + 'messaging.destination.name': 'test-queue', + }), + }), + ); + }); + }); + + describe('Breadcrumb Creation', () => { + let addBreadcrumbSpy: any; + + beforeEach(() => { + addBreadcrumbSpy = vi.spyOn(Breadcrumbs, 'addBreadcrumb'); + }); + + it('should create breadcrumb for producer operations', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supabase', + category: 'queue.publish', + message: 'queue.publish(test-queue)', + data: expect.objectContaining({ + 'messaging.message.id': '123', + 'messaging.destination.name': 'test-queue', + }), + }), + ); + }); + + it('should create breadcrumb for consumer operations', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + message: { foo: 'bar' }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'consumer-queue', + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supabase', + category: 'queue.process', + message: 'queue.process(consumer-queue)', + data: expect.objectContaining({ + 'messaging.message.id': '456', + 'messaging.destination.name': 'consumer-queue', + }), + }), + ); + }); + + it('should include batch count in producer breadcrumb', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 1 }, { msg_id: 2 }, { msg_id: 3 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send_batch', { + queue_name: 'batch-queue', + messages: [{ a: 1 }, { b: 2 }, { c: 3 }], + }); + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + 'messaging.batch.message_count': 3, + }), + }), + ); + }); + }); + + describe('Span Attributes', () => { + it('should set correct attributes on producer span', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 789 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'attr-test-queue', + message: { test: 'data' }, + }); + }); + + // Find the queue.publish span call + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.name === 'publish attr-test-queue'); + + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]).toEqual( + expect.objectContaining({ + name: 'publish attr-test-queue', + op: 'queue.publish', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.supabase.producer', + 'messaging.system': 'supabase', + 'messaging.destination.name': 'attr-test-queue', + 'messaging.message.body.size': expect.any(Number), + }), + }), + ); + }); + + it('should set correct attributes on consumer span', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 999, + message: { data: 'test' }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'consumer-attr-queue', + }); + + // Find the queue.process span call + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.name === 'process consumer-attr-queue'); + + expect(processSpanCall).toBeDefined(); + expect(processSpanCall?.[0]).toEqual( + expect.objectContaining({ + name: 'process consumer-attr-queue', + op: 'queue.process', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.supabase.consumer', + 'messaging.system': 'supabase', + 'messaging.destination.name': 'consumer-attr-queue', + }), + }), + ); + }); + }); + + describe('Message Body Size Limits', () => { + it('should calculate size for messages under 100KB', async () => { + const smallMessage = { data: 'x'.repeat(1000) }; // ~1KB + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 111 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'size-test-queue', + message: smallMessage, + }); + }); + + // If this completes without error, size was calculated + expect(mockRpcFunction).toHaveBeenCalled(); + }); + + it('should handle large messages gracefully', async () => { + // Create a message > 100KB + const largeMessage = { data: 'x'.repeat(110000) }; // ~110KB + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 222 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'large-message-queue', + message: largeMessage, + }); + }); + + // Size calculation skipped for large messages + expect(mockRpcFunction).toHaveBeenCalled(); + }); + + it('should handle non-serializable messages gracefully', async () => { + const circularRef: any = { foo: 'bar' }; + circularRef.self = circularRef; // Create circular reference + + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 333 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'circular-queue', + message: circularRef, + }); + }); + + // JSON.stringify fails gracefully for circular references + expect(mockRpcFunction).toHaveBeenCalled(); + }); + }); + + describe('Span Status', () => { + it('should set span status to OK for successful operations', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 777 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'success-queue', + message: { test: 'data' }, + }); + }); + + // Operation completed successfully + expect(mockRpcFunction).toHaveBeenCalled(); + }); + + it('should set span status to ERROR for failed operations', async () => { + const mockResponse: SupabaseResponse = { + data: [], + error: { + message: 'Queue operation failed', + code: 'QUEUE_ERROR', + }, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'error-queue', + message: { test: 'data' }, + }); + }); + + // Error handled, span should have error status + expect(mockRpcFunction).toHaveBeenCalled(); + }); + + it('should set span status to ERROR when exception thrown', async () => { + const mockError = new Error('Network failure'); + mockRpcFunction.mockRejectedValue(mockError); + instrumentSupabaseClient(mockSupabaseClient); + + await expect( + startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'exception-queue', + message: { test: 'data' }, + }); + }), + ).rejects.toThrow('Network failure'); + + expect(mockRpcFunction).toHaveBeenCalled(); + }); + }); +}); From e68a57644dfcc9a6e47d689471b2b1990a3d6385 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 5 Nov 2025 17:39:56 +0000 Subject: [PATCH 14/17] Add OTEL operation attributes to queue spans --- .../supabase-nextjs/tests/performance.test.ts | 5 +++ packages/core/src/integrations/supabase.ts | 17 ++++++- .../lib/integrations/supabase-queues.test.ts | 44 ++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index f47a79ad6e64..51247ff90f67 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -306,6 +306,7 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '1', + 'messaging.message.body.size': expect.any(Number), }, }); }); @@ -355,6 +356,7 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '2', + 'messaging.message.body.size': expect.any(Number), }, }); }); @@ -459,6 +461,7 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '1', + 'messaging.message.body.size': expect.any(Number), }, }); }); @@ -563,6 +566,7 @@ test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { data: { 'messaging.destination.name': 'todos', 'messaging.message.id': '2', + 'messaging.message.body.size': expect.any(Number), }, }); }); @@ -683,6 +687,7 @@ test('Sends queue batch publish spans with `rpc(...)`', async ({ page, baseURL } 'messaging.destination.name': 'todos', 'messaging.message.id': expect.stringMatching(/^\d+,\d+$/), 'messaging.batch.message_count': 2, + 'messaging.message.body.size': expect.any(Number), }, }); }); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index dcdbd05bc639..9468f009c920 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -491,9 +491,15 @@ function _calculateBatchLatency(messages: Array<{ enqueued_at?: string }>): numb * @param span - The span to process * @param res - The Supabase response * @param queueName - The name of the queue + * @param operationName - The queue operation name (e.g., 'pop', 'read', 'receive') * @returns The original response */ -function _processConsumerSpan(span: Span, res: SupabaseResponse, queueName: string | undefined): SupabaseResponse { +function _processConsumerSpan( + span: Span, + res: SupabaseResponse, + queueName: string | undefined, + operationName: string, +): SupabaseResponse { // Calculate latency for single message or batch average let latency: number | undefined; const isBatch = Array.isArray(res.data) && res.data.length > 1; @@ -522,6 +528,10 @@ function _processConsumerSpan(span: Span, res: SupabaseResponse, queueName: stri // Note: messaging.destination.name is already set in initial span attributes + // Set OTEL messaging semantic attributes + span.setAttribute('messaging.operation.type', 'process'); + span.setAttribute('messaging.operation.name', operationName); + if (latency !== undefined) { span.setAttribute('messaging.message.receive.latency', latency); } @@ -540,6 +550,7 @@ function _processConsumerSpan(span: Span, res: SupabaseResponse, queueName: stri const breadcrumbData: Record = {}; if (messageId) breadcrumbData['messaging.message.id'] = messageId; if (queueName) breadcrumbData['messaging.destination.name'] = queueName; + if (messageBodySize !== undefined) breadcrumbData['messaging.message.body.size'] = messageBodySize; _createQueueBreadcrumb('queue.process', queueName, breadcrumbData); // Handle errors in the response @@ -666,7 +677,7 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList }, span => { try { - const processedResponse = _processConsumerSpan(span, res, queueName); + const processedResponse = _processConsumerSpan(span, res, queueName, operationName); DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); @@ -788,6 +799,8 @@ function _instrumentRpcProducer(target: unknown, thisArg: unknown, argumentsList [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish', 'messaging.system': 'supabase', 'messaging.destination.name': queueName, + 'messaging.operation.name': operationName, + 'messaging.operation.type': 'publish', ...(messageBodySize !== undefined && { 'messaging.message.body.size': messageBodySize }), }, }, diff --git a/packages/core/test/lib/integrations/supabase-queues.test.ts b/packages/core/test/lib/integrations/supabase-queues.test.ts index 42d87daac1c1..eba4d77c754b 100644 --- a/packages/core/test/lib/integrations/supabase-queues.test.ts +++ b/packages/core/test/lib/integrations/supabase-queues.test.ts @@ -890,9 +890,49 @@ describe('Supabase Queue Instrumentation', () => { name: 'publish attr-test-queue', op: 'queue.publish', attributes: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.supabase.producer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase.queue.producer', 'messaging.system': 'supabase', 'messaging.destination.name': 'attr-test-queue', + 'messaging.operation.name': 'send', + 'messaging.operation.type': 'publish', + 'messaging.message.body.size': expect.any(Number), + }), + }), + ); + }); + + it('should set correct attributes on producer span for batch send', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 790 }, { msg_id: 791 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send_batch', { + queue_name: 'attr-test-queue-batch', + messages: [{ test: 'data1' }, { test: 'data2' }], + }); + }); + + // Find the queue.publish span call + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.name === 'publish attr-test-queue-batch'); + + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]).toEqual( + expect.objectContaining({ + name: 'publish attr-test-queue-batch', + op: 'queue.publish', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase.queue.producer', + 'messaging.system': 'supabase', + 'messaging.destination.name': 'attr-test-queue-batch', + 'messaging.operation.name': 'send_batch', + 'messaging.operation.type': 'publish', 'messaging.message.body.size': expect.any(Number), }), }), @@ -929,7 +969,7 @@ describe('Supabase Queue Instrumentation', () => { name: 'process consumer-attr-queue', op: 'queue.process', attributes: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.supabase.consumer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase.queue.consumer', 'messaging.system': 'supabase', 'messaging.destination.name': 'consumer-attr-queue', }), From 09ef20a5effe012b95a1ad316872a86a2ccf484c Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 5 Nov 2025 19:57:54 +0000 Subject: [PATCH 15/17] Fix browser integration tests --- .../suites/integrations/supabase/queues-rpc/test.ts | 8 ++++---- .../suites/integrations/supabase/queues-schema/test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts index d0f1534a404a..9653981d3c0e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/test.ts @@ -51,7 +51,7 @@ sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLo expect(queueSpans).toHaveLength(2); expect(queueSpans![0]).toMatchObject({ - description: 'supabase.db.rpc', + description: 'publish todos', parent_span_id: event.contexts?.trace?.span_id, span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -59,14 +59,14 @@ sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLo trace_id: event.contexts?.trace?.trace_id, data: expect.objectContaining({ 'sentry.op': 'queue.publish', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.producer', 'messaging.destination.name': 'todos', 'messaging.message.id': '0', }), }); expect(queueSpans![1]).toMatchObject({ - description: 'supabase.db.rpc', + description: 'process todos', parent_span_id: event.contexts?.trace?.span_id, span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -74,7 +74,7 @@ sentryTest('should capture Supabase queue spans from client.rpc', async ({ getLo trace_id: event.contexts?.trace?.trace_id, data: expect.objectContaining({ 'sentry.op': 'queue.process', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.consumer', 'messaging.destination.name': 'todos', 'messaging.message.id': '0', }), diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts index 6417f7796964..b5a04df2a0ec 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/test.ts @@ -51,7 +51,7 @@ sentryTest('should capture Supabase queue spans from client.schema(...).rpc', as expect(queueSpans).toHaveLength(2); expect(queueSpans![0]).toMatchObject({ - description: 'supabase.db.rpc', + description: 'publish todos', parent_span_id: event.contexts?.trace?.span_id, span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -59,14 +59,14 @@ sentryTest('should capture Supabase queue spans from client.schema(...).rpc', as trace_id: event.contexts?.trace?.trace_id, data: expect.objectContaining({ 'sentry.op': 'queue.publish', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.producer', 'messaging.destination.name': 'todos', 'messaging.message.id': '0', }), }); expect(queueSpans![1]).toMatchObject({ - description: 'supabase.db.rpc', + description: 'process todos', parent_span_id: event.contexts?.trace?.span_id, span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -74,7 +74,7 @@ sentryTest('should capture Supabase queue spans from client.schema(...).rpc', as trace_id: event.contexts?.trace?.trace_id, data: expect.objectContaining({ 'sentry.op': 'queue.process', - 'sentry.origin': 'auto.db.supabase', + 'sentry.origin': 'auto.db.supabase.queue.consumer', 'messaging.destination.name': 'todos', 'messaging.message.id': '0', }), From 23c89bb0b3aaff9e9181c288d95b57a5fdc471dd Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 21 Nov 2025 15:22:18 +0000 Subject: [PATCH 16/17] Improve data integrity and distributed tracing --- packages/core/src/integrations/supabase.ts | 252 +++++-- .../lib/integrations/supabase-queues.test.ts | 714 ++++++++++++++++++ 2 files changed, 891 insertions(+), 75 deletions(-) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 9468f009c920..1fa888f9affa 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -3,7 +3,7 @@ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; -import { getClient, getCurrentScope } from '../currentScopes'; +import { getClient, getCurrentScope, withIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; @@ -15,7 +15,7 @@ import { } from '../tracing/dynamicSamplingContext'; import type { IntegrationFn } from '../types-hoist/integration'; import type { Span, SpanAttributes } from '../types-hoist/span'; -import { dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage'; +import { dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader } from '../utils/baggage'; import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; @@ -245,6 +245,27 @@ export function translateFiltersIntoMethods(key: string, query: string): string return `${method}(${key}, ${value.join('.')})`; } +/** + * Normalizes RPC function names by stripping schema prefixes. + * Handles schema-qualified names like 'pgmq.send' → 'send' + * + * @param name - The RPC function name, potentially schema-qualified + * @returns The normalized function name without schema prefix + */ +function _normalizeRpcFunctionName(name: unknown): string { + if (!name || typeof name !== 'string') { + return ''; + } + + // Strip schema prefix: 'pgmq.send' → 'send', 'my_schema.pop' → 'pop' + if (name.includes('.')) { + const parts = name.split('.'); + return parts[parts.length - 1] || ''; + } + + return name; +} + /** * Creates a proxy handler for RPC methods to instrument queue operations. * This handler is shared between direct RPC calls and RPC calls via schema. @@ -260,9 +281,10 @@ function _createRpcProxyHandler(): ProxyHandler<(...args: unknown[]) => Promise< ): Promise { // Add try-catch for safety try { - const isProducerSpan = argumentsList[0] === 'send' || argumentsList[0] === 'send_batch'; - const isConsumerSpan = - argumentsList[0] === 'pop' || argumentsList[0] === 'receive' || argumentsList[0] === 'read'; + // Normalize RPC function name to handle schema-qualified names (e.g., 'pgmq.send' → 'send') + const normalizedName = _normalizeRpcFunctionName(argumentsList[0]); + const isProducerSpan = normalizedName === 'send' || normalizedName === 'send_batch'; + const isConsumerSpan = normalizedName === 'pop' || normalizedName === 'receive' || normalizedName === 'read'; if (!isProducerSpan && !isConsumerSpan) { const result = Reflect.apply(target, thisArg, argumentsList); @@ -491,15 +513,9 @@ function _calculateBatchLatency(messages: Array<{ enqueued_at?: string }>): numb * @param span - The span to process * @param res - The Supabase response * @param queueName - The name of the queue - * @param operationName - The queue operation name (e.g., 'pop', 'read', 'receive') * @returns The original response */ -function _processConsumerSpan( - span: Span, - res: SupabaseResponse, - queueName: string | undefined, - operationName: string, -): SupabaseResponse { +function _processConsumerSpan(span: Span, res: SupabaseResponse, queueName: string | undefined): SupabaseResponse { // Calculate latency for single message or batch average let latency: number | undefined; const isBatch = Array.isArray(res.data) && res.data.length > 1; @@ -526,11 +542,8 @@ function _processConsumerSpan( span.setAttribute('messaging.message.id', messageId); } - // Note: messaging.destination.name is already set in initial span attributes - - // Set OTEL messaging semantic attributes - span.setAttribute('messaging.operation.type', 'process'); - span.setAttribute('messaging.operation.name', operationName); + // Note: messaging.destination.name, messaging.operation.name, and messaging.operation.type + // are already set in initial span attributes if (latency !== undefined) { span.setAttribute('messaging.message.receive.latency', latency); @@ -593,7 +606,8 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); } - const [operationName, queueParams] = argumentsList as [string, unknown]; + const operationName = _normalizeRpcFunctionName(argumentsList[0]); + const queueParams = argumentsList[1]; if (!isPlainObject(queueParams)) { return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); @@ -618,6 +632,8 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', 'messaging.system': 'supabase', 'messaging.destination.name': queueName, + 'messaging.operation.name': operationName, + 'messaging.operation.type': 'process', } as const; const spanStartTime = timestampInSeconds(); @@ -631,7 +647,8 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList .then(res => { DEBUG_BUILD && debug.log('Consumer RPC call completed', { queueName, hasData: !!res.data }); - const { sentryTrace } = _extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); + // Extract trace context from message for distributed tracing + const { sentryTrace, baggage } = _extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); if (Array.isArray(res.data)) { res.data.forEach(item => { @@ -641,8 +658,12 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList }); } - // Extract producer span context for span link (before creating consumer span) + // Extract producer trace context for span link and propagation let producerSpanContext: { traceId: string; spanId: string; traceFlags: number } | undefined; + let producerPropagationContext: + | { traceId: string; parentSpanId: string; sampled: boolean; dsc?: Record } + | undefined; + if (sentryTrace) { const traceparentData = extractTraceparentData(sentryTrace); if (traceparentData?.traceId && traceparentData?.parentSpanId) { @@ -655,55 +676,117 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList spanId: traceparentData.parentSpanId, traceFlags, }; + + // Prepare propagation context for isolated scope + producerPropagationContext = { + traceId: traceparentData.traceId, + parentSpanId: traceparentData.parentSpanId, + sampled: traceparentData.parentSampled ?? false, + dsc: baggage ? parseBaggageHeader(baggage) : undefined, + }; } } - const runWithSpan = (): SupabaseResponse => - startSpan( - { - name: spanName, - op: 'queue.process', - startTime: spanStartTime, - attributes: spanAttributes, - // Add span link to producer span for distributed tracing across async queue boundary - links: producerSpanContext - ? [ - { - context: producerSpanContext, - attributes: { 'sentry.link.type': 'queue.producer' }, - }, - ] - : undefined, - }, - span => { - try { - const processedResponse = _processConsumerSpan(span, res, queueName, operationName); + const runWithSpan = (): SupabaseResponse => { + // If we have producer trace context, use isolated scope to prevent pollution + if (producerPropagationContext) { + return withIsolationScope(isolatedScope => { + // Set producer's propagation context in isolated scope + // This ensures the consumer span continues the producer's trace + isolatedScope.setPropagationContext({ + ...producerPropagationContext, + sampleRand: Math.random(), // Generate new sample rand for current execution context + }); - DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); + // Force transaction to make it a root span (not child of current span) + // This is critical to prevent the consumer span from becoming a child of + // an active HTTP request or other unrelated transaction + return startSpan( + { + name: spanName, + op: 'queue.process', + startTime: spanStartTime, + attributes: spanAttributes, + forceTransaction: true, // Makes this a root span, not a child + // Add span link to producer span for distributed tracing across async queue boundary + links: producerSpanContext + ? [ + { + context: producerSpanContext, + attributes: { 'sentry.link.type': 'queue.producer' }, + }, + ] + : undefined, + }, + span => { + try { + const processedResponse = _processConsumerSpan(span, res, queueName); - return processedResponse; - } catch (err: unknown) { - DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); + DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); - captureException(err, scope => { - scope.addEventProcessor(e => { - addExceptionMechanism(e, { - handled: false, - type: 'auto.db.supabase.queue', + return processedResponse; + } catch (err: unknown) { + DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); + + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName }); + return scope; }); - return e; + + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw err; + } + }, + ); + }); + // Isolated scope automatically discarded here, original scope restored + } else { + // No producer context, create regular span without isolation + return startSpan( + { + name: spanName, + op: 'queue.process', + startTime: spanStartTime, + attributes: spanAttributes, + }, + span => { + try { + const processedResponse = _processConsumerSpan(span, res, queueName); + + DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); + + return processedResponse; + } catch (err: unknown) { + DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); + + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', + }); + return e; + }); + scope.setContext('supabase', { queueName }); + return scope; }); - scope.setContext('supabase', { queueName }); - return scope; - }); - span.setStatus({ code: SPAN_STATUS_ERROR }); - throw err; - } - }, - ); + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw err; + } + }, + ); + } + }; - // Create consumer span with link to producer (not as a child) + // Create consumer span with isolated scope and forced transaction return runWithSpan(); }) .catch((err: unknown) => { @@ -777,7 +860,7 @@ function _instrumentRpcProducer(target: unknown, thisArg: unknown, argumentsList return Reflect.apply(target as (...args: unknown[]) => Promise, thisArg, argumentsList); } - const operationName = argumentsList[0] as 'send' | 'send_batch'; + const operationName = _normalizeRpcFunctionName(argumentsList[0]) as 'send' | 'send_batch'; const isBatch = operationName === 'send_batch'; DEBUG_BUILD && @@ -826,22 +909,40 @@ function _instrumentRpcProducer(target: unknown, thisArg: unknown, argumentsList ]; // Inject trace context into messages (avoid mutation) + // Only inject into plain objects to prevent payload corruption (primitives, arrays) if (sentryArgumentsQueueParams?.message) { - sentryArgumentsQueueParams.message = { - ...sentryArgumentsQueueParams.message, - _sentry: { - sentry_trace: sentryTrace, - baggage: sentryBaggage, - }, - }; + if (isPlainObject(sentryArgumentsQueueParams.message)) { + sentryArgumentsQueueParams.message = { + ...sentryArgumentsQueueParams.message, + _sentry: { + sentry_trace: sentryTrace, + baggage: sentryBaggage, + }, + }; + } else { + DEBUG_BUILD && + debug.warn( + 'Skipping trace propagation for non-object message payload. PGMQ supports primitives and arrays, but trace context can only be injected into plain objects.', + ); + } } else if (sentryArgumentsQueueParams?.messages) { - sentryArgumentsQueueParams.messages = sentryArgumentsQueueParams.messages.map(message => ({ - ...message, - _sentry: { - sentry_trace: sentryTrace, - baggage: sentryBaggage, - }, - })); + sentryArgumentsQueueParams.messages = sentryArgumentsQueueParams.messages.map(message => { + if (isPlainObject(message)) { + return { + ...message, + _sentry: { + sentry_trace: sentryTrace, + baggage: sentryBaggage, + }, + }; + } else { + DEBUG_BUILD && + debug.warn( + 'Skipping trace propagation for non-object message in batch. PGMQ supports primitives and arrays, but trace context can only be injected into plain objects.', + ); + return message; + } + }); } argumentsList[1] = sentryArgumentsQueueParams; @@ -1101,7 +1202,8 @@ function _createInstrumentedPostgRESTThen( const rpcIndex = pathParts.indexOf('rpc'); const rpcFunctionName = rpcIndex !== -1 && pathParts.length > rpcIndex + 1 ? pathParts[rpcIndex + 1] : undefined; - if (rpcFunctionName && QUEUE_RPC_OPERATIONS.has(rpcFunctionName)) { + // Normalize RPC function name to handle schema-qualified names (e.g., 'pgmq.send' → 'send') + if (rpcFunctionName && QUEUE_RPC_OPERATIONS.has(_normalizeRpcFunctionName(rpcFunctionName))) { // Queue RPC calls are instrumented in the dedicated queue instrumentation. return Reflect.apply(target, thisArg, argumentsList); } diff --git a/packages/core/test/lib/integrations/supabase-queues.test.ts b/packages/core/test/lib/integrations/supabase-queues.test.ts index eba4d77c754b..b1ede545d862 100644 --- a/packages/core/test/lib/integrations/supabase-queues.test.ts +++ b/packages/core/test/lib/integrations/supabase-queues.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Client } from '../../../src'; +import { getCurrentScope } from '../../../src'; import * as Breadcrumbs from '../../../src/breadcrumbs'; import * as CurrentScopes from '../../../src/currentScopes'; import type { SupabaseClientInstance, SupabaseResponse } from '../../../src/integrations/supabase'; @@ -7,6 +8,7 @@ import { instrumentSupabaseClient } from '../../../src/integrations/supabase'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../../src/semanticAttributes'; import * as Tracing from '../../../src/tracing'; import { startSpan } from '../../../src/tracing'; +import { getActiveSpan } from '../../../src/utils/spanUtils'; describe('Supabase Queue Instrumentation', () => { let mockClient: Client; @@ -1046,6 +1048,559 @@ describe('Supabase Queue Instrumentation', () => { }); }); + describe('Schema-Qualified RPC Names', () => { + it('should instrument schema-qualified producer RPC names', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('pgmq.send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + // Verify queue.publish span was created for schema-qualified name + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.publish'); + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]?.name).toBe('publish test-queue'); + }); + + it('should instrument schema-qualified consumer RPC names', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { foo: 'bar' }, + enqueued_at: new Date().toISOString(), + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('my_schema.pop', { + queue_name: 'test-queue', + }); + + // Verify queue.process span was created for schema-qualified name + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + expect(processSpanCall?.[0]?.name).toBe('process test-queue'); + }); + + it('should detect schema-qualified send_batch and set batch attributes', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 1 }, { msg_id: 2 }, { msg_id: 3 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + const addBreadcrumbSpy = vi.spyOn(Breadcrumbs, 'addBreadcrumb'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('pgmq.send_batch', { + queue_name: 'batch-test-queue', + messages: [{ foo: 'bar' }, { baz: 'qux' }, { test: 'data' }], + }); + }); + + // Verify span was created with normalized operation name + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.name === 'publish batch-test-queue'); + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]?.attributes).toEqual( + expect.objectContaining({ + 'messaging.operation.name': 'send_batch', // Normalized from 'pgmq.send_batch' + 'messaging.operation.type': 'publish', + 'messaging.destination.name': 'batch-test-queue', + }), + ); + + // Verify breadcrumb has batch count (messaging.batch.message_count is set after response) + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'queue.publish', + data: expect.objectContaining({ + 'messaging.batch.message_count': 3, // MUST be set in breadcrumb for batch operations + }), + }), + ); + }); + + it('should handle schema-qualified send for single messages', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 999 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('pgmq.send', { + queue_name: 'single-msg-queue', + message: { foo: 'bar' }, + }); + }); + + // Verify span attributes - operation name should be normalized + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.name === 'publish single-msg-queue'); + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]?.attributes).toEqual( + expect.objectContaining({ + 'messaging.operation.name': 'send', // Normalized from 'pgmq.send' + 'messaging.operation.type': 'publish', + 'messaging.destination.name': 'single-msg-queue', + }), + ); + + // Verify NO batch attributes are set for single messages + expect(publishSpanCall?.[0]?.attributes).not.toHaveProperty('messaging.batch.message_count'); + }); + + it('should handle multiple schema qualifiers', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 456 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('schema.nested.send', { + queue_name: 'nested-queue', + message: { test: 'data' }, + }); + }); + + // Should extract 'send' from 'schema.nested.send' + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.publish'); + expect(publishSpanCall).toBeDefined(); + }); + + it('should handle bare RPC names without schema', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 789 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'bare-queue', + message: { foo: 'bar' }, + }); + }); + + // Bare name should still work + const publishSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.publish'); + expect(publishSpanCall).toBeDefined(); + expect(publishSpanCall?.[0]?.name).toBe('publish bare-queue'); + }); + }); + + describe('Consumer - Schema-qualified RPC names', () => { + it('should normalize schema-qualified pop operation name', async () => { + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + read_ct: 0, + enqueued_at: new Date().toISOString(), + message: { foo: 'bar' }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + // Call with schema-qualified name + await mockSupabaseClient.rpc('pgmq.pop', { + queue_name: 'test_queue', + vt: 30, + qty: 1, + }); + + // Verify span attributes + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + + const spanOptions = processSpanCall?.[0]; + // CRITICAL: operation name must be normalized + expect(spanOptions?.attributes?.['messaging.operation.name']).toBe('pop'); // NOT 'pgmq.pop' + expect(spanOptions?.attributes?.['messaging.operation.type']).toBe('process'); + expect(spanOptions?.attributes?.['messaging.destination.name']).toBe('test_queue'); + }); + + it('should normalize schema-qualified receive operation name', async () => { + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + message: { test: 'data' }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('custom_schema.receive', { + queue_name: 'another_queue', + vt: 60, + qty: 5, + }); + + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + + const spanOptions = processSpanCall?.[0]; + expect(spanOptions?.attributes?.['messaging.operation.name']).toBe('receive'); // Normalized + expect(spanOptions?.attributes?.['messaging.operation.type']).toBe('process'); + expect(spanOptions?.attributes?.['messaging.destination.name']).toBe('another_queue'); + }); + + it('should normalize schema-qualified read operation name', async () => { + const consumerResponse: SupabaseResponse = { + data: [ + { msg_id: 1, message: {} }, + { msg_id: 2, message: {} }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pgmq.read', { + queue_name: 'batch_queue', + vt: 30, + qty: 10, + }); + + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + + const spanOptions = processSpanCall?.[0]; + expect(spanOptions?.attributes?.['messaging.operation.name']).toBe('read'); // Normalized + expect(spanOptions?.attributes?.['messaging.operation.type']).toBe('process'); + expect(spanOptions?.attributes?.['messaging.destination.name']).toBe('batch_queue'); + }); + }); + + describe('Payload Corruption Prevention', () => { + it('should not corrupt primitive message payloads (number)', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'primitive-queue', + message: 123, + }); + }); + + // Verify primitive payload was not corrupted + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toBe(123); // Should remain a number + expect(call[1].message).not.toHaveProperty('_sentry'); + }); + + it('should not corrupt primitive message payloads (string)', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 456 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'string-queue', + message: 'hello world', + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toBe('hello world'); // Should remain a string + }); + + it('should not corrupt primitive message payloads (boolean)', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 789 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'boolean-queue', + message: true, + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toBe(true); // Should remain a boolean + }); + + it('should not corrupt array message payloads', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 111 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const arrayMessage = [1, 2, 3]; + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'array-queue', + message: arrayMessage, + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toEqual([1, 2, 3]); // Should remain an array + expect(Array.isArray(call[1].message)).toBe(true); + }); + + it('should inject trace context into plain object messages', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 222 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'object-queue', + message: { foo: 'bar' }, + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toEqual({ + foo: 'bar', + _sentry: expect.objectContaining({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }), + }); + }); + + it('should not corrupt batch with mixed payload types', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 1 }, { msg_id: 2 }, { msg_id: 3 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send_batch', { + queue_name: 'mixed-batch', + messages: [123, 'hello', { foo: 'bar' }], + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].messages[0]).toBe(123); // Number unchanged + expect(call[1].messages[1]).toBe('hello'); // String unchanged + expect(call[1].messages[2]).toEqual({ + foo: 'bar', + _sentry: expect.objectContaining({ + sentry_trace: expect.any(String), + baggage: expect.any(String), + }), + }); // Object gets trace context + }); + + it('should handle null and undefined messages gracefully', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 333 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'null-queue', + message: null, + }); + }); + + const call = mockRpcFunction.mock.calls[0]; + expect(call[1].message).toBe(null); + }); + }); + + describe('Trace Continuation', () => { + it('should continue producer trace in consumer span (same trace ID)', async () => { + let capturedTraceId: string | undefined; + + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockImplementation(async (operation: string, params: any) => { + if (operation === 'send') { + const traceContext = params.message._sentry; + if (traceContext?.sentry_trace) { + // Extract trace ID from producer + capturedTraceId = traceContext.sentry_trace.split('-')[0]; + } + return mockResponse; + } + // Consumer: return message with trace context + return { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { + sentry_trace: `${capturedTraceId}-${'1'.repeat(16)}-1`, + baggage: 'sentry-environment=production', + }, + }, + }, + ], + status: 200, + }; + }); + + instrumentSupabaseClient(mockSupabaseClient); + + // Producer + await startSpan({ name: 'producer-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + const getCurrentScopeSpy = vi.spyOn(CurrentScopes, 'getCurrentScope'); + + // Consumer + await mockSupabaseClient.rpc('pop', { + queue_name: 'test-queue', + }); + + // Verify setPropagationContext was called + expect(getCurrentScopeSpy).toHaveBeenCalled(); + + // The consumer should have set propagation context with the same trace ID + const scope = getCurrentScopeSpy.mock.results[getCurrentScopeSpy.mock.results.length - 1]?.value; + if (scope && typeof scope.setPropagationContext === 'function') { + // Propagation context should have been set with producer's trace ID + expect(capturedTraceId).toBeDefined(); + } + }); + + it('should propagate baggage/DSC from producer to consumer', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + message: { + data: 'test', + _sentry: { + sentry_trace: '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production,sentry-release=1.0.0', + }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const getCurrentScopeSpy = vi.spyOn(CurrentScopes, 'getCurrentScope'); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'baggage-queue', + }); + + // Verify getCurrentScope was called (for setPropagationContext) + expect(getCurrentScopeSpy).toHaveBeenCalled(); + }); + + it('should handle missing trace context gracefully', async () => { + const mockResponse: SupabaseResponse = { + data: [ + { + msg_id: 789, + message: { foo: 'bar' }, // No _sentry metadata + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { + queue_name: 'no-trace-queue', + }); + + // Should still create consumer span without trace continuation + const processSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(processSpanCall).toBeDefined(); + }); + }); + describe('Span Status', () => { it('should set span status to OK for successful operations', async () => { const mockResponse: SupabaseResponse = { @@ -1107,4 +1662,163 @@ describe('Supabase Queue Instrumentation', () => { expect(mockRpcFunction).toHaveBeenCalled(); }); }); + + describe('Consumer - Trace continuation and scope isolation', () => { + it('should continue producer trace ID in consumer span', async () => { + // Producer trace context + const producerTraceId = '12345678901234567890123456789012'; + const producerSpanId = '1234567890123456'; + const sentryTrace = `${producerTraceId}-${producerSpanId}-1`; + + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 123, + message: { + foo: 'bar', + _sentry: { sentry_trace: sentryTrace }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { queue_name: 'test_queue' }); + + // Find the consumer span + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + + // Get the span created by the callback + const spanCallback = consumerSpanCall?.[1]; + expect(spanCallback).toBeDefined(); + + // Verify forceTransaction was set + const spanOptions = consumerSpanCall?.[0]; + expect(spanOptions?.forceTransaction).toBe(true); + }); + + it('should not pollute scope after consumer span completes', async () => { + const producerTraceId = '12345678901234567890123456789012'; + const producerSpanId = '1234567890123456'; + const sentryTrace = `${producerTraceId}-${producerSpanId}-1`; + + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 456, + message: { + test: 'data', + _sentry: { sentry_trace: sentryTrace }, + }, + }, + ], + status: 200, + }; + + // Get original scope state + const scopeBefore = getCurrentScope(); + const propContextBefore = scopeBefore.getPropagationContext(); + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + await mockSupabaseClient.rpc('receive', { queue_name: 'test_queue' }); + + // Get scope state after consumer completes + const scopeAfter = getCurrentScope(); + const propContextAfter = scopeAfter.getPropagationContext(); + + // CRITICAL: Scope must NOT have producer's trace ID + expect(propContextAfter.traceId).not.toBe(producerTraceId); + + // Scope should be restored to original state + expect(propContextAfter.traceId).toBe(propContextBefore.traceId); + }); + + it('should create consumer span as root transaction not child of HTTP request', async () => { + const producerTraceId = '12345678901234567890123456789012'; + const producerSpanId = 'aaaaaaaaaaaaaaaa'; + const sentryTrace = `${producerTraceId}-${producerSpanId}-1`; + + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 789, + message: { + _sentry: { sentry_trace: sentryTrace }, + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + // Simulate HTTP request transaction being active + await startSpan({ name: 'HTTP GET /api/test', op: 'http.server' }, async () => { + const httpSpan = getActiveSpan(); + expect(httpSpan).toBeDefined(); + + // Consumer RPC happens during HTTP request + await mockSupabaseClient.rpc('read', { queue_name: 'test_queue' }); + + // Find consumer span call + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + + const spanOptions = consumerSpanCall?.[0]; + + // CRITICAL: forceTransaction must be true to make it a root span + expect(spanOptions?.forceTransaction).toBe(true); + + // The consumer span should have producer's trace ID in the link + expect(spanOptions?.links).toBeDefined(); + expect(spanOptions?.links?.[0]?.context.traceId).toBe(producerTraceId); + expect(spanOptions?.links?.[0]?.context.spanId).toBe(producerSpanId); + }); + }); + + it('should handle consumer without producer context using regular span', async () => { + const consumerResponse: SupabaseResponse = { + data: [ + { + msg_id: 999, + message: { + foo: 'bar', + // No _sentry field - no producer context + }, + }, + ], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(consumerResponse); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await mockSupabaseClient.rpc('pop', { queue_name: 'test_queue' }); + + // Find the consumer span + const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); + expect(consumerSpanCall).toBeDefined(); + + const spanOptions = consumerSpanCall?.[0]; + + // Without producer context, should not force transaction + expect(spanOptions?.forceTransaction).toBeUndefined(); + + // No links should be created + expect(spanOptions?.links).toBeUndefined(); + }); + }); }); From 81d0767f945d1ba91616705563315679a3f046cc Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 26 Nov 2025 15:01:35 +0000 Subject: [PATCH 17/17] Use span links for consumer distributed tracing, fix prototype instrumentation --- .../integrations/supabase/queues-rpc/init.js | 2 +- .../supabase/queues-schema/init.js | 2 +- .../supabase-nextjs/tests/performance.test.ts | 28 ++- packages/core/src/integrations/supabase.ts | 177 +++++++----------- .../lib/integrations/supabase-queues.test.ts | 110 ++++++++--- 5 files changed, 171 insertions(+), 148 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js index 15309015bbd9..0aab91fa7446 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-rpc/init.js @@ -20,7 +20,7 @@ async function performQueueOperations() { try { await supabaseClient.rpc('send', { queue_name: 'todos', - msg: { title: 'Test Todo' }, + message: { title: 'Test Todo' }, }); await supabaseClient.rpc('pop', { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js index 0cbc629a2b3e..b880bc6f8fc8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/queues-schema/init.js @@ -20,7 +20,7 @@ async function performQueueOperations() { try { await supabaseClient.schema('pgmq_public').rpc('send', { queue_name: 'todos', - msg: { title: 'Test Todo' }, + message: { title: 'Test Todo' }, }); await supabaseClient.schema('pgmq_public').rpc('pop', { diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index 51247ff90f67..8f1a559a1da5 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -271,7 +271,10 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas const result = await fetch(`${baseURL}/api/queue/producer-schema`); expect(result.status).toBe(200); - expect(await result.json()).toEqual({ data: [1] }); + const responseData = await result.json(); + expect(responseData.data).toHaveLength(1); + expect(typeof responseData.data[0]).toBe('number'); + const messageId = responseData.data[0]; const transactionEvent = await httpTransactionPromise; @@ -280,7 +283,7 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas data: { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', - 'messaging.message.id': '1', + 'messaging.message.id': String(messageId), 'messaging.operation.type': 'publish', 'messaging.operation.name': 'send', 'messaging.message.body.size': expect.any(Number), @@ -305,7 +308,7 @@ test('Sends queue publish spans with `schema(...).rpc(...)`', async ({ page, bas message: 'queue.publish(todos)', data: { 'messaging.destination.name': 'todos', - 'messaging.message.id': '1', + 'messaging.message.id': String(messageId), 'messaging.message.body.size': expect.any(Number), }, }); @@ -323,14 +326,17 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { const transactionEvent = await httpTransactionPromise; expect(result.status).toBe(200); - expect(await result.json()).toEqual({ data: [2] }); + const responseData = await result.json(); + expect(responseData.data).toHaveLength(1); + expect(typeof responseData.data[0]).toBe('number'); + const messageId = responseData.data[0]; expect(transactionEvent.spans).toHaveLength(2); expect(transactionEvent.spans).toContainEqual({ data: { 'messaging.destination.name': 'todos', 'messaging.system': 'supabase', - 'messaging.message.id': '2', + 'messaging.message.id': String(messageId), 'messaging.operation.type': 'publish', 'messaging.operation.name': 'send', 'messaging.message.body.size': expect.any(Number), @@ -355,7 +361,7 @@ test('Sends queue publish spans with `rpc(...)`', async ({ page, baseURL }) => { message: 'queue.publish(todos)', data: { 'messaging.destination.name': 'todos', - 'messaging.message.id': '2', + 'messaging.message.id': String(messageId), 'messaging.message.body.size': expect.any(Number), }, }); @@ -453,6 +459,11 @@ test('Sends queue process spans with `schema(...).rpc(...)`', async ({ page, bas }, }); + // CRITICAL: Verify the link actually points to the producer span from the first request + // This ensures distributed tracing works correctly across separate HTTP transactions + expect(producerLink?.trace_id).toBe(producerSpan?.trace_id); + expect(producerLink?.span_id).toBe(producerSpan?.span_id); + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', @@ -558,6 +569,11 @@ test('Sends queue process spans with `rpc(...)`', async ({ page, baseURL }) => { }, }); + // CRITICAL: Verify the link actually points to the producer span from the first request + // This ensures distributed tracing works correctly across separate HTTP transactions + expect(producerLink?.trace_id).toBe(producerSpan?.trace_id); + expect(producerLink?.span_id).toBe(producerSpan?.span_id); + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 1fa888f9affa..4412bd284a20 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -3,7 +3,7 @@ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; -import { getClient, getCurrentScope, withIsolationScope } from '../currentScopes'; +import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; @@ -15,7 +15,7 @@ import { } from '../tracing/dynamicSamplingContext'; import type { IntegrationFn } from '../types-hoist/integration'; import type { Span, SpanAttributes } from '../types-hoist/span'; -import { dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader } from '../utils/baggage'; +import { dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage'; import { debug } from '../utils/debug-logger'; import { isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; @@ -27,6 +27,7 @@ export interface SupabaseClientConstructorType { prototype: { from: (table: string) => PostgRESTQueryBuilder; schema: (schema: string) => { rpc: (...args: unknown[]) => Promise }; + rpc: (...args: unknown[]) => Promise; }; rpc: (fn: string, params: Record) => Promise; } @@ -648,7 +649,7 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList DEBUG_BUILD && debug.log('Consumer RPC call completed', { queueName, hasData: !!res.data }); // Extract trace context from message for distributed tracing - const { sentryTrace, baggage } = _extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); + const { sentryTrace } = _extractTraceAndBaggageFromMessage(res.data?.[0]?.message || {}); if (Array.isArray(res.data)) { res.data.forEach(item => { @@ -658,11 +659,8 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList }); } - // Extract producer trace context for span link and propagation + // Extract producer trace context for span link let producerSpanContext: { traceId: string; spanId: string; traceFlags: number } | undefined; - let producerPropagationContext: - | { traceId: string; parentSpanId: string; sampled: boolean; dsc?: Record } - | undefined; if (sentryTrace) { const traceparentData = extractTraceparentData(sentryTrace); @@ -676,117 +674,58 @@ const _instrumentRpcConsumer = (target: unknown, thisArg: unknown, argumentsList spanId: traceparentData.parentSpanId, traceFlags, }; - - // Prepare propagation context for isolated scope - producerPropagationContext = { - traceId: traceparentData.traceId, - parentSpanId: traceparentData.parentSpanId, - sampled: traceparentData.parentSampled ?? false, - dsc: baggage ? parseBaggageHeader(baggage) : undefined, - }; } } const runWithSpan = (): SupabaseResponse => { - // If we have producer trace context, use isolated scope to prevent pollution - if (producerPropagationContext) { - return withIsolationScope(isolatedScope => { - // Set producer's propagation context in isolated scope - // This ensures the consumer span continues the producer's trace - isolatedScope.setPropagationContext({ - ...producerPropagationContext, - sampleRand: Math.random(), // Generate new sample rand for current execution context - }); - - // Force transaction to make it a root span (not child of current span) - // This is critical to prevent the consumer span from becoming a child of - // an active HTTP request or other unrelated transaction - return startSpan( - { - name: spanName, - op: 'queue.process', - startTime: spanStartTime, - attributes: spanAttributes, - forceTransaction: true, // Makes this a root span, not a child - // Add span link to producer span for distributed tracing across async queue boundary - links: producerSpanContext - ? [ - { - context: producerSpanContext, - attributes: { 'sentry.link.type': 'queue.producer' }, - }, - ] - : undefined, - }, - span => { - try { - const processedResponse = _processConsumerSpan(span, res, queueName); - - DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); - - return processedResponse; - } catch (err: unknown) { - DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); - - captureException(err, scope => { - scope.addEventProcessor(e => { - addExceptionMechanism(e, { - handled: false, - type: 'auto.db.supabase.queue', - }); - return e; - }); - scope.setContext('supabase', { queueName }); - return scope; - }); - - span.setStatus({ code: SPAN_STATUS_ERROR }); - throw err; - } - }, - ); - }); - // Isolated scope automatically discarded here, original scope restored - } else { - // No producer context, create regular span without isolation - return startSpan( - { - name: spanName, - op: 'queue.process', - startTime: spanStartTime, - attributes: spanAttributes, - }, - span => { - try { - const processedResponse = _processConsumerSpan(span, res, queueName); + // Create consumer span as child of current transaction (e.g., HTTP request) + // Add span link to producer span for distributed tracing across async queue boundary + return startSpan( + { + name: spanName, + op: 'queue.process', + startTime: spanStartTime, + attributes: spanAttributes, + // Add span link to producer span for distributed tracing across async queue boundary + links: producerSpanContext + ? [ + { + context: producerSpanContext, + attributes: { 'sentry.link.type': 'queue.producer' }, + }, + ] + : undefined, + }, + span => { + try { + const processedResponse = _processConsumerSpan(span, res, queueName); - DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); + DEBUG_BUILD && debug.log('Consumer span processed successfully', { queueName }); - return processedResponse; - } catch (err: unknown) { - DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); + return processedResponse; + } catch (err: unknown) { + DEBUG_BUILD && debug.log('Consumer span processing failed', { queueName, error: err }); - captureException(err, scope => { - scope.addEventProcessor(e => { - addExceptionMechanism(e, { - handled: false, - type: 'auto.db.supabase.queue', - }); - return e; + captureException(err, scope => { + scope.addEventProcessor(e => { + addExceptionMechanism(e, { + handled: false, + type: 'auto.db.supabase.queue', }); - scope.setContext('supabase', { queueName }); - return scope; + return e; }); + scope.setContext('supabase', { queueName }); + return scope; + }); - span.setStatus({ code: SPAN_STATUS_ERROR }); - throw err; - } - }, - ); - } + span.setStatus({ code: SPAN_STATUS_ERROR }); + throw err; + } + }, + ); }; - // Create consumer span with isolated scope and forced transaction + // Create consumer span as child of current transaction with span links for distributed tracing return runWithSpan(); }) .catch((err: unknown) => { @@ -1026,20 +965,32 @@ function _instrumentRpcProducer(target: unknown, thisArg: unknown, argumentsList } /** - * Instruments direct RPC calls on a Supabase client. + * Instruments direct RPC calls on a Supabase client's constructor prototype. * This handles the pattern: `client.rpc('function_name', params)` * Uses the shared proxy handler to route queue operations. * - * @param SupabaseClient - The Supabase client instance to instrument + * We instrument the prototype rather than individual instances to ensure consistent + * behavior across all clients sharing the same constructor and to avoid issues with + * Proxy property forwarding affecting the instrumentation marker on the original function. + * + * @param SupabaseClientConstructor - The Supabase client constructor to instrument */ -function _instrumentRpc(SupabaseClient: unknown): void { - const client = SupabaseClient as SupabaseClientInstance; +function _instrumentRpc(SupabaseClientConstructor: unknown): void { + const prototype = (SupabaseClientConstructor as SupabaseClientConstructorType).prototype; + + if (!prototype?.rpc) { + return; + } - if (!client.rpc) { + // Prevent double-wrapping if instrumentSupabaseClient is called multiple times + if (_isInstrumented(prototype.rpc)) { return; } - client.rpc = new Proxy(client.rpc, _createRpcProxyHandler()); + const wrappedRpc = new Proxy(prototype.rpc, _createRpcProxyHandler()); + prototype.rpc = wrappedRpc; + + _markAsInstrumented(prototype.rpc); } /** @@ -1454,7 +1405,7 @@ export const instrumentSupabaseClient = (supabaseClient: unknown): void => { _instrumentSupabaseClientConstructor(SupabaseClientConstructor); _instrumentRpcReturnedFromSchemaCall(SupabaseClientConstructor); - _instrumentRpc(supabaseClient as SupabaseClientInstance); + _instrumentRpc(SupabaseClientConstructor); _instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance); }; diff --git a/packages/core/test/lib/integrations/supabase-queues.test.ts b/packages/core/test/lib/integrations/supabase-queues.test.ts index b1ede545d862..a4f8fa341b8a 100644 --- a/packages/core/test/lib/integrations/supabase-queues.test.ts +++ b/packages/core/test/lib/integrations/supabase-queues.test.ts @@ -42,24 +42,22 @@ describe('Supabase Queue Instrumentation', () => { // Create a mock RPC function mockRpcFunction = vi.fn(); - // Create a mock Supabase client with proper structure - mockSupabaseClient = { - constructor: function SupabaseClient() { - // Constructor mock - }, - rpc: mockRpcFunction, - auth: { - signInWithPassword: vi.fn(), - admin: { - createUser: vi.fn(), - }, - }, - } as unknown as SupabaseClientInstance; - - // Add prototype methods for from() to support database instrumentation - (mockSupabaseClient.constructor as any).prototype = { + // Create a mock constructor with rpc on the prototype (matching real Supabase client behavior) + function MockSupabaseClient() {} + MockSupabaseClient.prototype = { from: vi.fn(), schema: vi.fn(), + rpc: mockRpcFunction, + }; + + // Create a mock Supabase client instance using Object.create to properly inherit from prototype + mockSupabaseClient = Object.create(MockSupabaseClient.prototype) as SupabaseClientInstance; + (mockSupabaseClient as any).constructor = MockSupabaseClient; + (mockSupabaseClient as any).auth = { + signInWithPassword: vi.fn(), + admin: { + createUser: vi.fn(), + }, }; }); @@ -1664,7 +1662,7 @@ describe('Supabase Queue Instrumentation', () => { }); describe('Consumer - Trace continuation and scope isolation', () => { - it('should continue producer trace ID in consumer span', async () => { + it('should create span links to producer span for distributed tracing', async () => { // Producer trace context const producerTraceId = '12345678901234567890123456789012'; const producerSpanId = '1234567890123456'; @@ -1694,13 +1692,18 @@ describe('Supabase Queue Instrumentation', () => { const consumerSpanCall = startSpanSpy.mock.calls.find(call => call[0]?.op === 'queue.process'); expect(consumerSpanCall).toBeDefined(); - // Get the span created by the callback - const spanCallback = consumerSpanCall?.[1]; - expect(spanCallback).toBeDefined(); - - // Verify forceTransaction was set + // Get the span options const spanOptions = consumerSpanCall?.[0]; - expect(spanOptions?.forceTransaction).toBe(true); + + // Verify span links are created for distributed tracing + expect(spanOptions?.links).toBeDefined(); + expect(spanOptions?.links).toHaveLength(1); + expect(spanOptions?.links?.[0].context.traceId).toBe(producerTraceId); + expect(spanOptions?.links?.[0].context.spanId).toBe(producerSpanId); + expect(spanOptions?.links?.[0].attributes?.['sentry.link.type']).toBe('queue.producer'); + + // Consumer span should NOT be a forced root transaction + expect(spanOptions?.forceTransaction).toBeUndefined(); }); it('should not pollute scope after consumer span completes', async () => { @@ -1741,7 +1744,7 @@ describe('Supabase Queue Instrumentation', () => { expect(propContextAfter.traceId).toBe(propContextBefore.traceId); }); - it('should create consumer span as root transaction not child of HTTP request', async () => { + it('should create consumer span as child of HTTP transaction with span links to producer', async () => { const producerTraceId = '12345678901234567890123456789012'; const producerSpanId = 'aaaaaaaaaaaaaaaa'; const sentryTrace = `${producerTraceId}-${producerSpanId}-1`; @@ -1777,13 +1780,14 @@ describe('Supabase Queue Instrumentation', () => { const spanOptions = consumerSpanCall?.[0]; - // CRITICAL: forceTransaction must be true to make it a root span - expect(spanOptions?.forceTransaction).toBe(true); + // Consumer span should be a child of HTTP transaction, not a forced root + expect(spanOptions?.forceTransaction).toBeUndefined(); - // The consumer span should have producer's trace ID in the link + // The consumer span should have producer's trace ID in the link for distributed tracing expect(spanOptions?.links).toBeDefined(); expect(spanOptions?.links?.[0]?.context.traceId).toBe(producerTraceId); expect(spanOptions?.links?.[0]?.context.spanId).toBe(producerSpanId); + expect(spanOptions?.links?.[0]?.attributes?.['sentry.link.type']).toBe('queue.producer'); }); }); @@ -1821,4 +1825,56 @@ describe('Supabase Queue Instrumentation', () => { expect(spanOptions?.links).toBeUndefined(); }); }); + + describe('Idempotency Guard', () => { + it('should not double-wrap rpc method when instrumentSupabaseClient is called multiple times', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 123 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + + // Instrument the same client multiple times + instrumentSupabaseClient(mockSupabaseClient); + instrumentSupabaseClient(mockSupabaseClient); + instrumentSupabaseClient(mockSupabaseClient); + + const startSpanSpy = vi.spyOn(Tracing, 'startSpan'); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { foo: 'bar' }, + }); + }); + + // Should only create ONE queue.publish span, not three + const publishSpanCalls = startSpanSpy.mock.calls.filter(call => call[0]?.op === 'queue.publish'); + expect(publishSpanCalls.length).toBe(1); + }); + + it('should only call the underlying RPC function once even after multiple instrumentations', async () => { + const mockResponse: SupabaseResponse = { + data: [{ msg_id: 456 }], + status: 200, + }; + + mockRpcFunction.mockResolvedValue(mockResponse); + + // Instrument multiple times + instrumentSupabaseClient(mockSupabaseClient); + instrumentSupabaseClient(mockSupabaseClient); + + await startSpan({ name: 'test-transaction' }, async () => { + await mockSupabaseClient.rpc('send', { + queue_name: 'test-queue', + message: { test: 'data' }, + }); + }); + + // The underlying mock RPC function should only be called once + expect(mockRpcFunction).toHaveBeenCalledTimes(1); + }); + }); });