Skip to content

Commit

Permalink
feat: Add no-sidecar Sentry SDK integration for overlay (#509)
Browse files Browse the repository at this point in the history
Ref #133.
  • Loading branch information
BYK authored Aug 28, 2024
1 parent 5013585 commit 4acbad0
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 52 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-donkeys-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@spotlightjs/overlay': minor
---

Add no-sidecar Sentry SDK integration for Spotlight overlay
18 changes: 16 additions & 2 deletions packages/overlay/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import Debugger from './components/Debugger';
import Trigger from './components/Trigger';
import type { Integration, IntegrationData } from './integrations/integration';
import * as db from './lib/db';
import { getSpotlightEventTarget } from './lib/eventTarget';
import { log } from './lib/logger';
import useKeyPress from './lib/useKeyPress';
Expand Down Expand Up @@ -96,6 +97,7 @@ export default function App({
const clearEventsUrl: string = `${origin}/clear`;

try {
await db.reset();
await fetch(clearEventsUrl, {
method: 'DELETE',
mode: 'cors',
Expand Down Expand Up @@ -139,8 +141,8 @@ export default function App({
navigate(e.detail);
};

const onEvent = ({ detail }: CustomEvent<{ contentType: string; data: string }>) => {
const { contentType, data } = detail;
type EventData = { contentType: string; data: string };
const dispatchToContentTypeListener = ({ contentType, data }: EventData) => {
const listener = contentTypeListeners[contentType];
if (!listener) {
log('Got event for unknown content type:', contentType);
Expand All @@ -149,6 +151,18 @@ export default function App({
listener({ data });
};

const onEvent = ({ detail }: CustomEvent<EventData>) => {
dispatchToContentTypeListener(detail);
db.add(detail);
};

// Populate from DB
db.getEntries().then(entries => {
for (const detail of entries as EventData[]) {
dispatchToContentTypeListener(detail);
}
});

return { clearEvents, onEvent, onOpen, onClose, onNavigate, onToggle };
}, [integrations, navigate, sidecarUrl, contentTypeListeners]);

Expand Down
4 changes: 1 addition & 3 deletions packages/overlay/src/integrations/sentry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,7 @@ function addSpotlightIntegrationToSentry(options: SentryIntegrationOptions) {
}

try {
const integration = spotlightIntegration({
sidecarUrl: options?.sidecarUrl,
});
const integration = spotlightIntegration();
sentryClient.addIntegration(integration);
} catch (e) {
warn('Failed to add Spotlight integration to Sentry', e);
Expand Down
61 changes: 14 additions & 47 deletions packages/overlay/src/integrations/sentry/sentry-integration.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,27 @@
import type { Client, Envelope, Event, Integration } from '@sentry/types';
import { serializeEnvelope } from '@sentry/utils';
import { DEFAULT_SIDECAR_URL } from '~/constants';
import { trigger } from '../../lib/eventTarget';
import { log } from '../../lib/logger';
import sentryDataCache from './data/sentryDataCache';
import { getNativeFetchImplementation } from './utils/fetch';

type SpotlightBrowserIntegrationOptions = {
/**
* The URL of the Sidecar instance to connect and forward events to.
* If not set, Spotlight will try to connect to the Sidecar running on DEFAULT_SIDECAR_URL.
*
* @default DEFAULT_SIDECAR_URL
*/
sidecarUrl?: string;
};

/**
* A Sentry integration for Spotlight integration that the Overlay will inject automatically.
* This integration does the following:
*
* - Drop transactions created from interactions with the Spotlight UI
* - Forward Sentry events sent from the browser SDK to the Sidecar instance running on
* either on http://localhost:8969/stream or on the supplied `sidecarUrl` option.
*
* @param options - Configuration options for the integration ({@link SpotlightBrowserIntegrationOptions})
* the same page via the "direct message" method (w/o a need for the sidecar)
*
* @returns Sentry integration for Spotlight.
*/
export const spotlightIntegration = (options?: SpotlightBrowserIntegrationOptions) => {
const _sidecarUrl = options?.sidecarUrl ?? DEFAULT_SIDECAR_URL;

export const spotlightIntegration = () => {
return {
name: 'SpotlightBrowser',
setupOnce: () => {
/* Empty function to ensure compatibility w/ JS SDK v7 >= 7.99.0 */
},
setup: () => {
log('Using Sidecar URL', _sidecarUrl);
log('Setting up the *direct* Sentry SDK integration for Spotlight');
},
processEvent: (event: Event) => {
// We don't want to send interaction transactions/root spans created from
Expand All @@ -53,39 +38,21 @@ export const spotlightIntegration = (options?: SpotlightBrowserIntegrationOption

return event;
},
afterAllSetup: (client: Client) => {
sendEnvelopesToSidecar(client, _sidecarUrl);
},
afterAllSetup: (client: Client) =>
client.on('beforeEnvelope', (envelope: Envelope) =>
trigger('event', { contentType: 'application/x-sentry-envelope', data: serializeEnvelope(envelope) }),
),
} satisfies Integration;
};

function sendEnvelopesToSidecar(client: Client, sidecarUrl: string) {
const makeFetch = getNativeFetchImplementation();

client.on('beforeEnvelope', (envelope: Envelope) => {
makeFetch(sidecarUrl, {
method: 'POST',
body: serializeEnvelope(envelope),
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
mode: 'cors',
}).catch(err => {
console.error(
`Sentry SDK can't connect to Sidecar is it running? See: https://spotlightjs.com/sidecar/npx/`,
err,
);
});
});
}

/**
* Flags if the event is a transaction created from an interaction with the spotlight UI.
*/
function isSpotlightInteraction(event: Event): boolean {
if (event.type === 'transaction' && event.contexts?.trace?.op === 'ui.action.click' && event.spans) {
const hasSpotlightDescriptor = event.spans.find(s => s.description?.includes('#sentry-spotlight'));
return !!hasSpotlightDescriptor;
}
return false;
return (
(event.type === 'transaction' &&
event.contexts?.trace?.op === 'ui.action.click' &&
event.spans?.some(s => s.description?.includes('#sentry-spotlight'))) ||
false
);
}
106 changes: 106 additions & 0 deletions packages/overlay/src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
export const MAX_AGE = 30 * 60 * 1000; // 30 minutes
export const DB_NAME = 'SentrySpotlight';
export const OBJECT_STORE_NAME = 'events';
export const DB_VERSION = 2;

function promiseWithResolvers<T = unknown>() {
let resolve: (value: T | PromiseLike<T>) => void;
let reject: (reason?: unknown) => void;
const promise = new Promise((rs, rj) => {
resolve = rs;
reject = rj;
});
// @ts-expect-error ts2454 -- This is not a valid error as the promise constructor callback is executed immediately
return { resolve, reject, promise };
}

let _DB: IDBDatabase | null = null;

export function clearDBCache() {
_DB = null;
}

function createDB(): Promise<IDBDatabase> {
const { promise, resolve, reject } = promiseWithResolvers();
const rejectFromErrorEvent = (evt: Event) => reject((evt.target as IDBOpenDBRequest).error);
const openDBRequest = indexedDB.open(DB_NAME, DB_VERSION);
openDBRequest.onerror = rejectFromErrorEvent;
openDBRequest.onupgradeneeded = () => {
const db = openDBRequest.result;
try {
db.deleteObjectStore(OBJECT_STORE_NAME);
} catch (_err) {
// just ignore if it is missing
}
db.createObjectStore(OBJECT_STORE_NAME, { autoIncrement: true }).createIndex('timestamp', 'timestamp', {
unique: false,
});
};

openDBRequest.onsuccess = () => {
const db = openDBRequest.result;
// Clean up expired entries
const tx = db.transaction([OBJECT_STORE_NAME], 'readwrite');
tx.onerror = rejectFromErrorEvent;
tx.oncomplete = () => resolve(db);
const cursorRequest = tx
.objectStore(OBJECT_STORE_NAME)
.index('timestamp')
.openCursor(IDBKeyRange.upperBound(new Date(Date.now() - MAX_AGE)));
cursorRequest.onerror = rejectFromErrorEvent;
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
cursor.delete();
cursor.continue();
};
};
return promise as Promise<IDBDatabase>;
}

async function getDB() {
if (!_DB) {
_DB = await createDB();
}
return _DB;
}

export async function add(value: unknown) {
const { promise, resolve, reject } = promiseWithResolvers();
const rejectFromErrorEvent = (evt: Event) => reject((evt.target as IDBOpenDBRequest).error);
const db = await getDB();
const tx = db.transaction([OBJECT_STORE_NAME], 'readwrite');
tx.onerror = rejectFromErrorEvent;

const addRequest = tx.objectStore(OBJECT_STORE_NAME).add({
value,
timestamp: new Date(),
});
addRequest.onerror = rejectFromErrorEvent;
addRequest.onsuccess = () => resolve(addRequest.result);
return promise;
}

export async function getEntries() {
const { promise, resolve, reject } = promiseWithResolvers();
const rejectFromErrorEvent = (evt: Event) => reject((evt.target as IDBOpenDBRequest).error);
const db = await getDB();
const tx = db.transaction([OBJECT_STORE_NAME], 'readonly');
tx.onerror = rejectFromErrorEvent;
const getRequest = tx.objectStore(OBJECT_STORE_NAME).getAll();
getRequest.onerror = rejectFromErrorEvent;
getRequest.onsuccess = () => resolve(getRequest.result.map(({ value }) => value));
return promise;
}

export async function reset() {
const { promise, resolve, reject } = promiseWithResolvers();
const rejectFromErrorEvent = (evt: Event) => reject((evt.target as IDBOpenDBRequest).error);
const db = await getDB();
const tx = db.transaction([OBJECT_STORE_NAME], 'readwrite');
tx.onerror = rejectFromErrorEvent;
const getRequest = tx.objectStore(OBJECT_STORE_NAME).clear();
getRequest.onerror = rejectFromErrorEvent;
getRequest.onsuccess = () => resolve(true);
return promise;
}

0 comments on commit 4acbad0

Please sign in to comment.