Skip to content
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
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "sentry-spotlight",
"name": "@sentry/spotlight",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "tsc && vite build && yarn build:sidecar",
"build:sidecar": "tsc --module nodenext --moduleResolution nodenext --esModuleInterop false --target esnext src/node/sidecar.ts --outDir dist/",
"watch:build": "vite build --watch",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
Expand All @@ -18,6 +19,10 @@
".": {
"import": "./dist/sentry-spotlight.js",
"require": "./dist/sentry-spotlight.umd.cjs"
},
"./sidecar": {
"import": "./dist/sidecar.js",
"require": "./dist/sidecar.cjs"
}
},
"dependencies": {
Expand Down Expand Up @@ -47,5 +52,9 @@
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
},
"volta": {
"node": "18.18.0",
"yarn": "1.22.19"
}
}
38 changes: 38 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function init({
} = {}) {
if (typeof document === "undefined") return;

hookIntoSentry();
connectToRelay(relay);

// build shadow dom container to contain styles
Expand Down Expand Up @@ -71,6 +72,43 @@ export function pushEnvelope(envelope: Envelope) {
dataCache.pushEnvelope(envelope);
}

function hookIntoSentry() {
// A very hacky way to hook into Sentry's SDK
// but we love hacks
(window as any).__SENTRY__.hub._stack[0].client.setupIntegrations(true);
(window as any).__SENTRY__.hub._stack[0].client.on("beforeEnvelope", (envelope: any) => {
fetch('http://localhost:8969/stream', {
method: 'POST',
body: serializeEnvelope(envelope),
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
mode: 'cors',
})
.catch(err => {
console.error(err);
});
});
}

function serializeEnvelope(envelope: Envelope): string {
const [envHeaders, items] = envelope;

// Initially we construct our envelope as a string and only convert to binary chunks if we encounter binary data
const parts: string[] = [];
parts.push(JSON.stringify(envHeaders));

for (const item of items) {
const [itemHeaders, payload] = item;

parts.push(`\n${JSON.stringify(itemHeaders)}\n`);

parts.push(JSON.stringify(payload));
}

return parts.join("");
}

function connectToRelay(relay: string = DEFAULT_RELAY) {
console.log("[Spotlight] Connecting to relay");
const source = new EventSource(relay || DEFAULT_RELAY);
Expand Down
209 changes: 209 additions & 0 deletions src/node/sidecar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { Server, createServer } from "http";

const defaultResponse = `<!doctype html>
<html>
<head>
<title>pipe</title>
</head>
<body>
<pre id="output"></pre>
<script type="text/javascript">
const Output = document.getElementById("output");
var EvtSource = new EventSource('/stream');
EvtSource.onmessage = function (event) {
Output.appendChild(document.createTextNode(event.data));
Output.appendChild(document.createElement("br"));
};
</script>
</body>
</html>`;

function generate_uuidv4() {
let dt = new Date().getTime();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
let rnd = Math.random() * 16;
rnd = (dt + rnd) % 16 | 0;
dt = Math.floor(dt / 16);
return (c === "x" ? rnd : (rnd & 0x3) | 0x8).toString(16);
});
}

class MessageBuffer<T> {
private _size: number;
private _items: [number, T][];
private _writePos: number = 0;
private _head: number = 0;
private _timeout: number = 10;
private _readers: Map<string, (item: T) => void>;

public constructor(size = 100) {
this._size = size;
this._items = new Array(size);
this._readers = new Map<string, (item: T) => void>();
}

public put(item: T): void {
const curTime = new Date().getTime();
this._items[this._writePos % this._size] = [curTime, item];
this._writePos += 1;
if (this._head === this._writePos) {
this._head += 1;
}

const minTime = curTime - this._timeout * 1000;
let atItem;
while (this._head < this._writePos) {
atItem = this._items[this._head % this._size];
if (atItem === undefined) break;
if (atItem[0] > minTime) break;
this._head += 1;
}
}

public subscribe(callback: (item: T) => void): string {
const readerId = generate_uuidv4();
this._readers.set(readerId, callback);
setTimeout(() => this.stream(readerId));
return readerId;
}

public unsubscribe(readerId: string): void {
this._readers.delete(readerId);
}

public stream(readerId: string, readPos?: number): void {
const cb = this._readers.get(readerId);
if (!cb) return;

let atReadPos = typeof readPos === "undefined" ? this._head : readPos;
let item;
while (true) {
item = this._items[atReadPos % this._size];
if (typeof item === "undefined") {
break;
}
cb(item[1]);
atReadPos += 1;
}
setTimeout(() => this.stream(readerId, atReadPos), 500);
}
}

const ENVELOPE = "envelope";
const EVENT = "event";

type Payload = [string, string];

let serverInstance: Server;

function getCorsHeader(): { [name: string]: string } {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Headers": "*",
};
}

function startServer(buffer: MessageBuffer<Payload>, port: number): Server {
const server = createServer((req, res) => {
console.log(`[spotlight] Received request ${req.method} ${req.url}`);
if (req.headers.accept && req.headers.accept == "text/event-stream") {
if (req.url == "/stream") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
...getCorsHeader(),
Connection: "keep-alive",
});
res.flushHeaders();

const sub = buffer.subscribe(([payloadType, data]) => {
res.write(`event:${payloadType}\n`);
data.split("\n").forEach((line) => {
res.write(`data:${line}\n`);
});
res.write("\n");
});

req.on("close", () => {
buffer.unsubscribe(sub);
});
} else {
res.writeHead(404);
res.end();
}
} else {
if (req.url == "/stream") {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Cache-Control": "no-cache",
...getCorsHeader(),
});
res.end();
} else if (req.method === "POST") {
let body: string = "";
req.on("readable", () => {
const chunk = req.read();
if (chunk !== null) body += chunk;
});
req.on("end", () => {
const payloadType =
req.headers["content-type"] === "application/x-sentry-envelope"
? ENVELOPE
: EVENT;
buffer.put([payloadType, body]);
res.writeHead(204, {
"Cache-Control": "no-cache",
...getCorsHeader(),
Connection: "keep-alive",
});
res.end();
});
} else {
res.writeHead(200, {
"Content-Type": "text/html",
});
res.write(defaultResponse);
res.end();
}
} else {
res.writeHead(404);
res.end();
}
}
});

server.on("error", (e) => {
if ("code" in e && e.code === "EADDRINUSE") {
// console.error('[Spotlight] Address in use, retrying...');
setTimeout(() => {
server.close();
server.listen(port);
}, 5000);
}
});
server.listen(port, () => {
console.log(`[Spotlight] Sidecar listening on ${port}`);
});

return server;
}

export function setupSidecar(): void {
const buffer: MessageBuffer<Payload> = new MessageBuffer<Payload>();

if (!serverInstance) {
serverInstance = startServer(buffer, 8969);
}
}

function shutdown() {
if (serverInstance) {
console.log("[Spotlight] Shutting down server");
serverInstance.close();
}
}

process.on("SIGTERM", () => {
shutdown();
});
Loading