Skip to content

feat(core): Add Supabase Queues support #15921

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Sentry from '@sentry/browser';

import { createClient } from '@supabase/supabase-js';
window.Sentry = Sentry;

const supabaseClient = createClient('https://test.supabase.co', 'test-key', {
db: {
schema: 'pgmq_public',
},
});

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })],
tracesSampleRate: 1.0,
});

// Simulate queue operations
async function performQueueOperations() {
try {
await supabaseClient.rpc('enqueue', {
queue_name: 'todos',
msg: { title: 'Test Todo' },
});

await supabaseClient.rpc('dequeue', {
queue_name: 'todos',
});
} catch (error) {
Sentry.captureException(error);
}
}

performQueueOperations();
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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',
},
});
});
}

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);

if (shouldSkipTracingTest()) {
return;
}

const url = await getLocalTestUrl({ testDir: __dirname });

const event = await getFirstSentryEnvelopeRequest<Event>(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',
}),
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Sentry from '@sentry/browser';

import { createClient } from '@supabase/supabase-js';
window.Sentry = Sentry;

const supabaseClient = createClient('https://test.supabase.co', 'test-key', {
db: {
schema: 'pgmq_public',
},
});

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })],
tracesSampleRate: 1.0,
});

// Simulate queue operations
async function performQueueOperations() {
try {
await supabaseClient.schema('pgmq_public').rpc('enqueue', {
queue_name: 'todos',
msg: { title: 'Test Todo' },
});

await supabaseClient.schema('pgmq_public').rpc('dequeue', {
queue_name: 'todos',
});
} catch (error) {
Sentry.captureException(error);
}
}

performQueueOperations();
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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',
},
});
});
}

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);

if (shouldSkipTracingTest()) {
return;
}

const url = await getLocalTestUrl({ testDir: __dirname });

const event = await getFirstSentryEnvelopeRequest<Event>(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',
}),
});
});
86 changes: 80 additions & 6 deletions packages/core/src/integrations/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { DEBUG_BUILD } from '../debug-build';
import { logger } from '../utils-hoist/logger';
import { isPlainObject } from '../utils-hoist/is';

export interface SupabaseClientConstructor {
prototype: {
from: (table: string) => PostgRESTQueryBuilder;
schema: (schema: string) => { rpc: (...args: unknown[]) => Promise<unknown> };
};
rpc: (fn: string, params: Record<string, unknown>) => Promise<unknown>;
}

const AUTH_OPERATIONS_TO_INSTRUMENT = [
'reauthenticate',
'signInAnonymously',
Expand Down Expand Up @@ -114,12 +122,6 @@ export interface SupabaseBreadcrumb {
};
}

export interface SupabaseClientConstructor {
prototype: {
from: (table: string) => PostgRESTQueryBuilder;
};
}

export interface PostgRESTProtoThenable {
then: <T>(
onfulfilled?: ((value: T) => T | PromiseLike<T>) | null,
Expand Down Expand Up @@ -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';
Comment on lines +238 to +239
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this recently changed, but here they show send and pop as rpc args: https://supabase.com/docs/guides/queues/quickstart#enqueueing-and-dequeueing-messages 🤔


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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can add the messaging.system attribute to be 'supabase' as described in https://develop.sentry.dev/sdk/telemetry/traces/modules/queues/

},
},
async span => {
return (Reflect.apply(target, thisArg, argumentsList) as Promise<unknown>).then((res: unknown) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably also end the span when it throws/rejects? We can also set the status of the span then.

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) {
Expand Down Expand Up @@ -496,6 +568,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);
};

Expand Down