diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 70104de6d7c3..bdcced28494b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -154,4 +154,5 @@ export type { BunOptions } from './types'; export { BunClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunSqliteIntegration } from './integrations/bunsqlite'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunsqlite.ts b/packages/bun/src/integrations/bunsqlite.ts new file mode 100644 index 000000000000..a06a5f3d0565 --- /dev/null +++ b/packages/bun/src/integrations/bunsqlite.ts @@ -0,0 +1,216 @@ +import type { IntegrationFn } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, captureException, defineIntegration, startSpan } from '@sentry/core'; + +const INTEGRATION_NAME = 'BunSqlite'; + +const _bunSqliteIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentBunSqlite(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments `bun:sqlite` to automatically create spans and capture errors. + * + * Enabled by default in the Bun SDK. + * + * ```js + * Sentry.init({ + * integrations: [ + * Sentry.bunSqliteIntegration(), + * ], + * }) + * ``` + */ +export const bunSqliteIntegration = defineIntegration(_bunSqliteIntegration); + +let hasPatchedBunSqlite = false; + +export function _resetBunSqliteInstrumentation(): void { + hasPatchedBunSqlite = false; +} + +/** + * Instruments bun:sqlite by patching the Database class. + */ +function instrumentBunSqlite(): void { + if (hasPatchedBunSqlite) { + return; + } + + try { + const sqliteModule = require('bun:sqlite'); + + if (!sqliteModule || !sqliteModule.Database) { + return; + } + + const OriginalDatabase = sqliteModule.Database; + + const DatabaseProxy = new Proxy(OriginalDatabase, { + construct(target, args) { + const instance = new target(...args); + if (args[0]) { + Object.defineProperty(instance, '_sentryDbName', { + value: args[0], + writable: false, + enumerable: false, + configurable: false, + }); + } + return instance; + }, + }); + + for (const prop in OriginalDatabase) { + if (OriginalDatabase.hasOwnProperty(prop)) { + DatabaseProxy[prop] = OriginalDatabase[prop]; + } + } + + sqliteModule.Database = DatabaseProxy; + + OriginalDatabase.prototype.constructor = DatabaseProxy; + + const proto = OriginalDatabase.prototype; + const methodsToInstrument = ['query', 'prepare', 'run', 'exec', 'transaction']; + + const inParentSpanMap = new WeakMap(); + const dbNameMap = new WeakMap(); + + methodsToInstrument.forEach(method => { + if (proto[method]) { + const originalMethod = proto[method]; + + if (originalMethod._sentryInstrumented) { + return; + } + + proto[method] = function (this: any, ...args: any[]) { + let dbName = this._sentryDbName || dbNameMap.get(this); + + if (!dbName && this.filename) { + dbName = this.filename; + dbNameMap.set(this, dbName); + } + + const sql = method !== 'transaction' && args[0] && typeof args[0] === 'string' ? args[0] : undefined; + + if (inParentSpanMap.get(this) && method === 'prepare') { + const result = originalMethod.apply(this, args); + if (result) { + return instrumentStatement(result, sql, dbName); + } + return result; + } + + return startSpan( + { + name: sql || 'db.sql.' + method, + op: `db.sql.${method}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': method, + ...(sql && { 'db.statement': sql }), + ...(dbName && { 'db.name': dbName }), + }, + }, + span => { + try { + const wasInParentSpan = inParentSpanMap.get(this) || false; + if (method === 'query') { + inParentSpanMap.set(this, true); + } + + const result = originalMethod.apply(this, args); + + if (wasInParentSpan) { + inParentSpanMap.set(this, wasInParentSpan); + } else { + inParentSpanMap.delete(this); + } + + if (method === 'prepare' && result) { + return instrumentStatement(result, sql, dbName); + } + + return result; + } catch (error) { + span.setStatus({ code: 2, message: 'internal_error' }); + captureException(error, { + mechanism: { + type: 'bun.sqlite', + handled: false, + data: { + function: method, + }, + }, + }); + throw error; + } + }, + ); + }; + + // Mark the instrumented method + proto[method]._sentryInstrumented = true; + } + }); + + hasPatchedBunSqlite = true; + } catch (error) { + // Silently fail if bun:sqlite is not available + } +} + +/** + * Instruments a Statement instance. + */ +function instrumentStatement(statement: any, sql?: string, dbName?: string): any { + const methodsToInstrument = ['run', 'get', 'all', 'values']; + + methodsToInstrument.forEach(method => { + if (typeof statement[method] === 'function') { + statement[method] = new Proxy(statement[method], { + apply(target, thisArg, args) { + return startSpan( + { + name: `db.statement.${method}`, + op: `db.sql.statement.${method}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': method, + ...(sql && { 'db.statement': sql }), + ...(dbName && { 'db.name': dbName }), + }, + }, + span => { + try { + return target.apply(thisArg, args); + } catch (error) { + span.setStatus({ code: 2, message: 'internal_error' }); + captureException(error, { + mechanism: { + type: 'bun.sqlite.statement', + handled: false, + data: { + function: method, + }, + }, + }); + throw error; + } + }, + ); + }, + }); + } + }); + + return statement; +} diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 641567504818..df8aeef6efab 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -22,6 +22,7 @@ import { onUnhandledRejectionIntegration, } from '@sentry/node'; import { bunServerIntegration } from './integrations/bunserver'; +import { bunSqliteIntegration } from './integrations/bunsqlite'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -49,6 +50,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { modulesIntegration(), // Bun Specific bunServerIntegration(), + bunSqliteIntegration(), ...(hasSpansEnabled(_options) ? getAutoPerformanceIntegrations() : []), ]; } diff --git a/packages/bun/test/integrations/bunsqlite.test.ts b/packages/bun/test/integrations/bunsqlite.test.ts new file mode 100644 index 000000000000..24fde412ce4f --- /dev/null +++ b/packages/bun/test/integrations/bunsqlite.test.ts @@ -0,0 +1,346 @@ +import * as SentryCore from '@sentry/core'; +import { beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { bunSqliteIntegration, _resetBunSqliteInstrumentation } from '../../src/integrations/bunsqlite'; + +describe('Bun SQLite Integration', () => { + let startSpanSpy: any; + let captureExceptionSpy: any; + + beforeAll(() => { + startSpanSpy = spyOn(SentryCore, 'startSpan'); + captureExceptionSpy = spyOn(SentryCore, 'captureException'); + + _resetBunSqliteInstrumentation(); + + const integration = bunSqliteIntegration(); + integration.setupOnce(); + }); + + beforeEach(() => { + startSpanSpy.mockClear(); + captureExceptionSpy.mockClear(); + }); + + test('has the correct name', () => { + const integration = bunSqliteIntegration(); + expect(integration.name).toBe('BunSqlite'); + }); + + describe('Database instrumentation', () => { + test('instruments query method', () => { + delete require.cache[require.resolve('bun:sqlite')]; + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + startSpanSpy.mockClear(); + + const sql = 'SELECT * FROM users'; + db.query(sql); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: sql, + op: 'db.sql.query', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'query', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + + test('instruments run method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + + const sql = 'INSERT INTO users (name) VALUES (?)'; + const params = ['John']; + db.run(sql, ...params); + + // Reset after exec call + startSpanSpy.mockClear(); + db.run(sql, ...params); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: sql, + op: 'db.sql.run', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'run', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + + test('instruments exec method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + const sql = 'CREATE TABLE users (id INTEGER PRIMARY KEY)'; + db.exec(sql); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: sql, + op: 'db.sql.exec', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'exec', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + + test('instruments transaction method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + startSpanSpy.mockClear(); + + const fn = () => { + db.run('INSERT INTO users (name) VALUES (?)', 'Alice'); + }; + db.transaction(fn)(); + + expect( + startSpanSpy.mock.calls.some( + call => call[0].name === 'db.sql.transaction' && call[0].op === 'db.sql.transaction', + ), + ).toBe(true); + }); + + test('instruments prepare method and returns instrumented statement', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + startSpanSpy.mockClear(); + + const sql = 'SELECT * FROM users WHERE id = ?'; + const statement = db.prepare(sql); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: sql, + op: 'db.sql.prepare', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'prepare', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + + startSpanSpy.mockClear(); + statement.run(1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'db.statement.run', + op: 'db.sql.statement.run', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'run', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + }); + + describe('Statement instrumentation', () => { + test('instruments statement get method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + db.run('INSERT INTO users (id, name) VALUES (1, "John")'); + + const sql = 'SELECT * FROM users WHERE id = ?'; + const statement = db.prepare(sql); + + startSpanSpy.mockClear(); + statement.get(1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'db.statement.get', + op: 'db.sql.statement.get', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'get', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + + test('instruments statement all method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + db.run('INSERT INTO users (name) VALUES ("John"), ("Jane")'); + + const sql = 'SELECT * FROM users'; + const statement = db.prepare(sql); + + startSpanSpy.mockClear(); + statement.all(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'db.statement.all', + op: 'db.sql.statement.all', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'all', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + + test('instruments statement values method', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + db.run('INSERT INTO users (name) VALUES ("John"), ("Jane")'); + + const sql = 'SELECT id FROM users'; + const statement = db.prepare(sql); + + startSpanSpy.mockClear(); + statement.values(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'db.statement.values', + op: 'db.sql.statement.values', + attributes: { + 'sentry.origin': 'auto.db.bun.sqlite', + 'db.system': 'sqlite', + 'db.operation': 'values', + 'db.statement': sql, + 'db.name': ':memory:', + }, + }, + expect.any(Function), + ); + }); + }); + + describe('Error handling', () => { + test('captures exceptions and sets span status on error', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + captureExceptionSpy.mockClear(); + + expect(() => db.query('SELECT * FROM invalid_table')).toThrow(); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(expect.any(Error), { + mechanism: { + type: 'bun.sqlite', + handled: false, + data: { + function: 'query', + }, + }, + }); + }); + + test('captures exceptions from statement methods', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + const statement = db.prepare('INSERT INTO users VALUES (?)'); + + captureExceptionSpy.mockClear(); + + statement.run(1); + expect(() => statement.run(1)).toThrow(); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(expect.any(Error), { + mechanism: { + type: 'bun.sqlite.statement', + handled: false, + data: { + function: 'run', + }, + }, + }); + }); + }); + + describe('Edge cases', () => { + test('handles databases without optional methods gracefully', () => { + const { Database } = require('bun:sqlite'); + const db = new Database(':memory:'); + + expect(() => { + db.query('SELECT 1'); + db.exec('CREATE TABLE test (id INTEGER)'); + }).not.toThrow(); + }); + + test('multiple database instances are instrumented independently', () => { + const { Database } = require('bun:sqlite'); + const db1 = new Database(':memory:'); + const db2 = new Database(':memory:'); + + db1.exec('CREATE TABLE test1 (id INTEGER)'); + db2.exec('CREATE TABLE test2 (id INTEGER)'); + + startSpanSpy.mockClear(); + + db1.query('SELECT * FROM test1'); + db2.query('SELECT * FROM test2'); + + expect(startSpanSpy).toHaveBeenCalledTimes(2); + expect(startSpanSpy.mock.calls[0]?.[0]?.attributes?.['db.statement']).toBe('SELECT * FROM test1'); + expect(startSpanSpy.mock.calls[1]?.[0]?.attributes?.['db.statement']).toBe('SELECT * FROM test2'); + }); + }); +});