@sentry/core
Version:
Base implementation for all Sentry JavaScript SDKs
456 lines (387 loc) • 13 kB
JavaScript
import { addBreadcrumb } from '../breadcrumbs.js';
import { DEBUG_BUILD } from '../debug-build.js';
import { captureException } from '../exports.js';
import { defineIntegration } from '../integration.js';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes.js';
import '../tracing/errors.js';
import { isPlainObject } from '../utils-hoist/is.js';
import '../utils-hoist/debug-build.js';
import { logger } from '../utils-hoist/logger.js';
import '../utils-hoist/time.js';
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, setHttpStatus } from '../tracing/spanstatus.js';
import { startSpan } from '../tracing/trace.js';
// Based on Kamil Ogórek's work on:
// https://github.com/supabase-community/sentry-integration-js
const AUTH_OPERATIONS_TO_INSTRUMENT = [
'reauthenticate',
'signInAnonymously',
'signInWithOAuth',
'signInWithIdToken',
'signInWithOtp',
'signInWithPassword',
'signInWithSSO',
'signOut',
'signUp',
'verifyOtp',
];
const AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT = [
'createUser',
'deleteUser',
'listUsers',
'getUserById',
'updateUserById',
'inviteUserByEmail',
];
const FILTER_MAPPINGS = {
eq: 'eq',
neq: 'neq',
gt: 'gt',
gte: 'gte',
lt: 'lt',
lte: 'lte',
like: 'like',
'like(all)': 'likeAllOf',
'like(any)': 'likeAnyOf',
ilike: 'ilike',
'ilike(all)': 'ilikeAllOf',
'ilike(any)': 'ilikeAnyOf',
is: 'is',
in: 'in',
cs: 'contains',
cd: 'containedBy',
sr: 'rangeGt',
nxl: 'rangeGte',
sl: 'rangeLt',
nxr: 'rangeLte',
adj: 'rangeAdjacent',
ov: 'overlaps',
fts: '',
plfts: 'plain',
phfts: 'phrase',
wfts: 'websearch',
not: 'not',
};
const DB_OPERATIONS_TO_INSTRUMENT = ['select', 'insert', 'upsert', 'update', 'delete'];
function markAsInstrumented(fn) {
try {
(fn ).__SENTRY_INSTRUMENTED__ = true;
} catch {
// ignore errors here
}
}
function isInstrumented(fn) {
try {
return (fn ).__SENTRY_INSTRUMENTED__;
} catch {
return false;
}
}
/**
* Extracts the database operation type from the HTTP method and headers
* @param method - The HTTP method of the request
* @param headers - The request headers
* @returns The database operation type ('select', 'insert', 'upsert', 'update', or 'delete')
*/
function extractOperation(method, headers = {}) {
switch (method) {
case 'GET': {
return 'select';
}
case 'POST': {
if (headers['Prefer']?.includes('resolution=')) {
return 'upsert';
} else {
return 'insert';
}
}
case 'PATCH': {
return 'update';
}
case 'DELETE': {
return 'delete';
}
default: {
return '<unknown-op>';
}
}
}
/**
* Translates Supabase filter parameters into readable method names for tracing
* @param key - The filter key from the URL search parameters
* @param query - The filter value from the URL search parameters
* @returns A string representation of the filter as a method call
*/
function translateFiltersIntoMethods(key, query) {
if (query === '' || query === '*') {
return 'select(*)';
}
if (key === 'select') {
return `select(${query})`;
}
if (key === 'or' || key.endsWith('.or')) {
return `${key}${query}`;
}
const [filter, ...value] = query.split('.');
let method;
// Handle optional `configPart` of the filter
if (filter?.startsWith('fts')) {
method = 'textSearch';
} else if (filter?.startsWith('plfts')) {
method = 'textSearch[plain]';
} else if (filter?.startsWith('phfts')) {
method = 'textSearch[phrase]';
} else if (filter?.startsWith('wfts')) {
method = 'textSearch[websearch]';
} else {
method = (filter && FILTER_MAPPINGS[filter ]) || 'filter';
}
return `${method}(${key}, ${value.join('.')})`;
}
function instrumentAuthOperation(operation, isAdmin = false) {
return new Proxy(operation, {
apply(target, thisArg, argumentsList) {
return startSpan(
{
name: operation.name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.auth.${isAdmin ? 'admin.' : ''}${operation.name}`,
},
},
span => {
return Reflect.apply(target, thisArg, argumentsList)
.then((res) => {
if (res && typeof res === 'object' && 'error' in res && res.error) {
span.setStatus({ code: SPAN_STATUS_ERROR });
captureException(res.error, {
mechanism: {
handled: false,
},
});
} else {
span.setStatus({ code: SPAN_STATUS_OK });
}
span.end();
return res;
})
.catch((err) => {
span.setStatus({ code: SPAN_STATUS_ERROR });
span.end();
captureException(err, {
mechanism: {
handled: false,
},
});
throw err;
})
.then(...argumentsList);
},
);
},
});
}
function instrumentSupabaseAuthClient(supabaseClientInstance) {
const auth = supabaseClientInstance.auth;
if (!auth || isInstrumented(supabaseClientInstance.auth)) {
return;
}
for (const operation of AUTH_OPERATIONS_TO_INSTRUMENT) {
const authOperation = auth[operation];
if (!authOperation) {
continue;
}
if (typeof supabaseClientInstance.auth[operation] === 'function') {
supabaseClientInstance.auth[operation] = instrumentAuthOperation(authOperation);
}
}
for (const operation of AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT) {
const authOperation = auth.admin[operation];
if (!authOperation) {
continue;
}
if (typeof supabaseClientInstance.auth.admin[operation] === 'function') {
supabaseClientInstance.auth.admin[operation] = instrumentAuthOperation(authOperation, true);
}
}
markAsInstrumented(supabaseClientInstance.auth);
}
function instrumentSupabaseClientConstructor(SupabaseClient) {
if (isInstrumented((SupabaseClient ).prototype.from)) {
return;
}
(SupabaseClient ).prototype.from = new Proxy(
(SupabaseClient ).prototype.from,
{
apply(target, thisArg, argumentsList) {
const rv = Reflect.apply(target, thisArg, argumentsList);
const PostgRESTQueryBuilder = (rv ).constructor;
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder );
return rv;
},
},
);
markAsInstrumented((SupabaseClient ).prototype.from);
}
function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder) {
if (isInstrumented((PostgRESTFilterBuilder.prototype ).then)) {
return;
}
(PostgRESTFilterBuilder.prototype ).then = new Proxy(
(PostgRESTFilterBuilder.prototype ).then,
{
apply(target, thisArg, argumentsList) {
const operations = DB_OPERATIONS_TO_INSTRUMENT;
const typedThis = thisArg ;
const operation = extractOperation(typedThis.method, typedThis.headers);
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);
}
const pathParts = typedThis.url.pathname.split('/');
const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : '';
const description = `from(${table})`;
const queryItems = [];
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 = Object.create(null);
if (isPlainObject(typedThis.body)) {
for (const [key, value] of Object.entries(typedThis.body)) {
body[key] = value;
}
}
const attributes = {
'db.table': table,
'db.schema': typedThis.schema,
'db.url': typedThis.url.origin,
'db.sdk': typedThis.headers['X-Client-Info'],
'db.system': 'postgresql',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.${operation}`,
};
if (queryItems.length) {
attributes['db.query'] = queryItems;
}
if (Object.keys(body).length) {
attributes['db.body'] = body;
}
return startSpan(
{
name: description,
attributes,
},
span => {
return (Reflect.apply(target, thisArg, []) )
.then(
(res) => {
if (span) {
if (res && typeof res === 'object' && 'status' in res) {
setHttpStatus(span, res.status || 500);
}
span.end();
}
if (res.error) {
const err = new Error(res.error.message) ;
if (res.error.code) {
err.code = res.error.code;
}
if (res.error.details) {
err.details = res.error.details;
}
const supabaseContext = {};
if (queryItems.length) {
supabaseContext.query = queryItems;
}
if (Object.keys(body).length) {
supabaseContext.body = body;
}
captureException(err, {
contexts: {
supabase: supabaseContext,
},
});
}
const breadcrumb = {
type: 'supabase',
category: `db.${operation}`,
message: description,
};
const data = {};
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) => {
if (span) {
setHttpStatus(span, 500);
span.end();
}
throw err;
},
)
.then(...argumentsList);
},
);
},
},
);
markAsInstrumented((PostgRESTFilterBuilder.prototype ).then);
}
function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder) {
// 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 )[operation])) {
continue;
}
(PostgRESTQueryBuilder.prototype )[operation ] = new Proxy(
(PostgRESTQueryBuilder.prototype )[operation ],
{
apply(target, thisArg, argumentsList) {
const rv = Reflect.apply(target, thisArg, argumentsList);
const PostgRESTFilterBuilder = (rv ).constructor;
DEBUG_BUILD && logger.log(`Instrumenting ${operation} operation's PostgRESTFilterBuilder`);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
return rv;
},
},
);
markAsInstrumented((PostgRESTQueryBuilder.prototype )[operation]);
}
}
const instrumentSupabaseClient = (supabaseClient) => {
if (!supabaseClient) {
DEBUG_BUILD && logger.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;
instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseAuthClient(supabaseClient );
};
const INTEGRATION_NAME = 'Supabase';
const _supabaseIntegration = ((supabaseClient) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
},
name: INTEGRATION_NAME,
};
}) ;
const supabaseIntegration = defineIntegration((options) => {
return _supabaseIntegration(options.supabaseClient);
}) ;
export { DB_OPERATIONS_TO_INSTRUMENT, FILTER_MAPPINGS, extractOperation, instrumentSupabaseClient, supabaseIntegration, translateFiltersIntoMethods };
//# sourceMappingURL=supabase.js.map