-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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', | ||
}), | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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', | ||
|
@@ -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, | ||
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can add the |
||
}, | ||
}, | ||
async span => { | ||
return (Reflect.apply(target, thisArg, argumentsList) as Promise<unknown>).then((res: unknown) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
@@ -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); | ||
}; | ||
|
||
|
There was a problem hiding this comment.
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
andpop
as rpc args: https://supabase.com/docs/guides/queues/quickstart#enqueueing-and-dequeueing-messages 🤔