Skip to content
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

feat: Add no-sidecar Sentry SDK integration for overlay #509

Merged
merged 5 commits into from
Aug 28, 2024
Merged
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
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 = () => {
BYK marked this conversation as resolved.
Show resolved Hide resolved
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;
}
Loading