Skip to content

Commit 21fb51b

Browse files
lforstAbhiPrasad
andauthored
test: Add test utility to intercept requests to Sentry (#7271)
Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent 8e27ff9 commit 21fb51b

File tree

1 file changed

+233
-0
lines changed

1 file changed

+233
-0
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import type { Envelope, EnvelopeItem, Event } from '@sentry/types';
2+
import { parseEnvelope } from '@sentry/utils';
3+
import * as fs from 'fs';
4+
import * as http from 'http';
5+
import * as https from 'https';
6+
import type { AddressInfo } from 'net';
7+
import * as os from 'os';
8+
import * as path from 'path';
9+
import * as util from 'util';
10+
11+
const readFile = util.promisify(fs.readFile);
12+
const writeFile = util.promisify(fs.writeFile);
13+
14+
interface EventProxyServerOptions {
15+
/** Port to start the event proxy server at. */
16+
port: number;
17+
/** The name for the proxy server used for referencing it with listener functions */
18+
proxyServerName: string;
19+
}
20+
21+
interface SentryRequestCallbackData {
22+
envelope: Envelope;
23+
rawProxyRequestBody: string;
24+
rawSentryResponseBody: string;
25+
sentryResponseStatusCode?: number;
26+
}
27+
28+
/**
29+
* Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel`
30+
* option to this server (like this `tunnel: http://localhost:${port option}/`).
31+
*/
32+
export async function startEventProxyServer(options: EventProxyServerOptions): Promise<void> {
33+
const eventCallbackListeners: Set<(data: string) => void> = new Set();
34+
35+
const proxyServer = http.createServer((proxyRequest, proxyResponse) => {
36+
const proxyRequestChunks: Uint8Array[] = [];
37+
38+
proxyRequest.addListener('data', (chunk: Buffer) => {
39+
proxyRequestChunks.push(chunk);
40+
});
41+
42+
proxyRequest.addListener('error', err => {
43+
throw err;
44+
});
45+
46+
proxyRequest.addListener('end', () => {
47+
const proxyRequestBody = Buffer.concat(proxyRequestChunks).toString();
48+
const envelopeHeader: { dsn?: string } = JSON.parse(proxyRequestBody.split('\n')[0]);
49+
50+
if (!envelopeHeader.dsn) {
51+
throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.');
52+
}
53+
54+
const { origin, pathname, host } = new URL(envelopeHeader.dsn);
55+
56+
const projectId = pathname.substring(1);
57+
const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`;
58+
59+
proxyRequest.headers.host = host;
60+
61+
const sentryResponseChunks: Uint8Array[] = [];
62+
63+
const sentryRequest = https.request(
64+
sentryIngestUrl,
65+
{ headers: proxyRequest.headers, method: proxyRequest.method },
66+
sentryResponse => {
67+
sentryResponse.addListener('data', (chunk: Buffer) => {
68+
proxyResponse.write(chunk, 'binary');
69+
sentryResponseChunks.push(chunk);
70+
});
71+
72+
sentryResponse.addListener('end', () => {
73+
eventCallbackListeners.forEach(listener => {
74+
const rawProxyRequestBody = Buffer.concat(proxyRequestChunks).toString();
75+
const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString();
76+
77+
const data: SentryRequestCallbackData = {
78+
envelope: parseEnvelope(rawProxyRequestBody, new TextEncoder(), new TextDecoder()),
79+
rawProxyRequestBody,
80+
rawSentryResponseBody,
81+
sentryResponseStatusCode: sentryResponse.statusCode,
82+
};
83+
84+
listener(Buffer.from(JSON.stringify(data)).toString('base64'));
85+
});
86+
proxyResponse.end();
87+
});
88+
89+
sentryResponse.addListener('error', err => {
90+
throw err;
91+
});
92+
93+
proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers);
94+
},
95+
);
96+
97+
sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary');
98+
sentryRequest.end();
99+
});
100+
});
101+
102+
const proxyServerStartupPromise = new Promise<void>(resolve => {
103+
proxyServer.listen(options.port, () => {
104+
resolve();
105+
});
106+
});
107+
108+
const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => {
109+
eventCallbackResponse.statusCode = 200;
110+
eventCallbackResponse.setHeader('connection', 'keep-alive');
111+
112+
const callbackListener = (data: string): void => {
113+
eventCallbackResponse.write(data.concat('\n'), 'utf8');
114+
};
115+
116+
eventCallbackListeners.add(callbackListener);
117+
118+
eventCallbackRequest.on('close', () => {
119+
eventCallbackListeners.delete(callbackListener);
120+
});
121+
122+
eventCallbackRequest.on('error', () => {
123+
eventCallbackListeners.delete(callbackListener);
124+
});
125+
});
126+
127+
const eventCallbackServerStartupPromise = new Promise<void>(resolve => {
128+
eventCallbackServer.listen(0, () => {
129+
const port = String((eventCallbackServer.address() as AddressInfo).port);
130+
void registerCallbackServerPort(options.proxyServerName, port).then(resolve);
131+
});
132+
});
133+
134+
await eventCallbackServerStartupPromise;
135+
await proxyServerStartupPromise;
136+
return;
137+
}
138+
139+
export async function waitForRequest(
140+
proxyServerName: string,
141+
callback: (eventData: SentryRequestCallbackData) => boolean,
142+
): Promise<SentryRequestCallbackData> {
143+
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);
144+
145+
return new Promise<SentryRequestCallbackData>((resolve, reject) => {
146+
const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {
147+
let eventContents = '';
148+
149+
response.on('error', err => {
150+
reject(err);
151+
});
152+
153+
response.on('data', (chunk: Buffer) => {
154+
const chunkString = chunk.toString('utf8');
155+
chunkString.split('').forEach(char => {
156+
if (char === '\n') {
157+
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
158+
Buffer.from(eventContents, 'base64').toString('utf8'),
159+
);
160+
if (callback(eventCallbackData)) {
161+
response.destroy();
162+
resolve(eventCallbackData);
163+
}
164+
eventContents = '';
165+
} else {
166+
eventContents = eventContents.concat(char);
167+
}
168+
});
169+
});
170+
});
171+
172+
request.end();
173+
});
174+
}
175+
176+
export function waitForEnvelopeItem(
177+
proxyServerName: string,
178+
callback: (envelopeItem: EnvelopeItem) => boolean,
179+
): Promise<EnvelopeItem> {
180+
return new Promise((resolve, reject) => {
181+
waitForRequest(proxyServerName, eventData => {
182+
const envelopeItems = eventData.envelope[1];
183+
for (const envelopeItem of envelopeItems) {
184+
if (callback(envelopeItem)) {
185+
resolve(envelopeItem);
186+
return true;
187+
}
188+
}
189+
return false;
190+
}).catch(reject);
191+
});
192+
}
193+
194+
export function waitForError(proxyServerName: string, callback: (transactionEvent: Event) => boolean): Promise<Event> {
195+
return new Promise((resolve, reject) => {
196+
waitForEnvelopeItem(proxyServerName, envelopeItem => {
197+
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
198+
if (envelopeItemHeader.type === 'event' && callback(envelopeItemBody as Event)) {
199+
resolve(envelopeItemBody as Event);
200+
return true;
201+
}
202+
return false;
203+
}).catch(reject);
204+
});
205+
}
206+
207+
export function waitForTransaction(
208+
proxyServerName: string,
209+
callback: (transactionEvent: Event) => boolean,
210+
): Promise<Event> {
211+
return new Promise((resolve, reject) => {
212+
waitForEnvelopeItem(proxyServerName, envelopeItem => {
213+
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
214+
if (envelopeItemHeader.type === 'transaction' && callback(envelopeItemBody as Event)) {
215+
resolve(envelopeItemBody as Event);
216+
return true;
217+
}
218+
return false;
219+
}).catch(reject);
220+
});
221+
}
222+
223+
const TEMP_FILE_PREFIX = 'event-proxy-server-';
224+
225+
async function registerCallbackServerPort(serverName: string, port: string): Promise<void> {
226+
const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);
227+
await writeFile(tmpFilePath, port, { encoding: 'utf8' });
228+
}
229+
230+
async function retrieveCallbackServerPort(serverName: string): Promise<string> {
231+
const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);
232+
return await readFile(tmpFilePath, 'utf8');
233+
}

0 commit comments

Comments
 (0)