Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const NODE_EXPORTS_IGNORE = [
'_INTERNAL_normalizeCollectionInterval',
// Only needed by framework SDKs that enable the managed tunnel after `init`
'_INTERNAL_installTunnelHooks',
// Only needed by the AWS Lambda extension
'_INTERNAL_getProxyAgent',
// not exported by bun
'nativeNodeFetchIntegration',
];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { buffer } from 'node:stream/consumers';
import {
consoleSandbox,
Expand All @@ -8,6 +9,7 @@ import {
getEnvelopeEndpointWithUrlEncodedAuth,
makeDsn,
} from '@sentry/core';
import { _INTERNAL_getProxyAgent as getProxyAgent } from '@sentry/node';
import { DEBUG_BUILD } from './debug-build';

/**
Expand Down Expand Up @@ -116,7 +118,7 @@ export class AwsLambdaExtension {
/**
* Starts the Sentry tunnel.
*/
public startSentryTunnel(): void {
public startSentryTunnel(port = 9000): http.Server {
const allowedDsnComponents = getSentryDSNFromEnv();

if (!allowedDsnComponents) {
Expand All @@ -134,10 +136,7 @@ export class AwsLambdaExtension {
if (req.method === 'POST' && req.url?.startsWith('/envelope')) {
try {
const buf = await buffer(req);
// Extract the actual bytes from the Buffer by slicing its underlying ArrayBuffer
// This ensures we get only the data portion without any padding or offset
const envelopeBytes = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
const envelope = new TextDecoder().decode(envelopeBytes);
const envelope = buf.toString('utf8');
const piece = envelope.split('\n')[0];
const header = JSON.parse(piece || '{}') as { dsn?: string };
const envelopeDsn = header.dsn;
Expand Down Expand Up @@ -165,14 +164,7 @@ export class AwsLambdaExtension {
res.end(JSON.stringify({ error: 'Invalid DSN' }));
return;
}
const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn);

fetch(upstreamSentryUrl, {
method: 'POST',
body: envelopeBytes as BodyInit,
}).catch(err => {
DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err);
});
forwardEnvelope(new URL(getEnvelopeEndpointWithUrlEncodedAuth(dsn)), buf);

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({}));
Expand All @@ -187,17 +179,33 @@ export class AwsLambdaExtension {
}
});

server.listen(9000, () => {
DEBUG_BUILD && debug.log('Sentry proxy listening on port 9000');
server.listen(port, () => {
DEBUG_BUILD && debug.log(`Sentry proxy listening on port ${port}`);
});

server.on('error', err => {
DEBUG_BUILD && debug.error('Error starting Sentry proxy', err);
process.exit(1);
});

return server;
}
}

function forwardEnvelope(url: URL, body: Buffer): void {
const req = (url.protocol === 'https:' ? https : http).request(
url,
{ method: 'POST', agent: getProxyAgent(url) },
res => {
res.resume();
},
);
req.on('error', err => {
DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err);
});
req.end(body);
}

/**
* DSN components allowed for the Lambda extension `/envelope` tunnel, derived from `SENTRY_DSN`.
*
Expand Down
111 changes: 111 additions & 0 deletions packages/aws-serverless/test/aws-lambda-extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { once } from 'node:events';
import * as http from 'node:http';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { AwsLambdaExtension, getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension';
Expand Down Expand Up @@ -89,6 +90,116 @@ describe('AwsLambdaExtension.next', () => {
});
});

describe('AwsLambdaExtension.startSentryTunnel', () => {
const servers: Array<http.Server> = [];

async function listen(server: http.Server): Promise<number> {
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return (server.address() as AddressInfo).port;
}

async function setup(): Promise<{
sendEnvelope: () => Promise<void>;
received: Promise<{ url: string | undefined; body: string }>;
upstreamPort: number;
proxyPort: number;
connects: string[];
}> {
let resolveReceived: (value: { url: string | undefined; body: string }) => void;
const received = new Promise<{ url: string | undefined; body: string }>(resolve => {
resolveReceived = resolve;
});
const upstreamPort = await listen(
http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
resolveReceived({ url: req.url, body: Buffer.concat(chunks).toString() });
res.end('{}');
});
}),
);

const connects: string[] = [];
const proxy = http.createServer();
proxy.on('connect', (req: http.IncomingMessage, clientSocket: net.Socket) => {
connects.push(req.url ?? '');
const [host, port] = (req.url ?? '').split(':');
const upstreamSocket = net.connect(Number(port), host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
});
const proxyPort = await listen(proxy);

const dsn = `http://public@127.0.0.1:${upstreamPort}/1`;
vi.stubEnv('SENTRY_DSN', dsn);

const tunnel = new AwsLambdaExtension().startSentryTunnel(0);
servers.push(tunnel);
await once(tunnel, 'listening');
const tunnelPort = (tunnel.address() as AddressInfo).port;

const envelope = `${JSON.stringify({ dsn })}\n${JSON.stringify({ type: 'event' })}\n{}`;
const sendEnvelope = async (): Promise<void> => {
const res = await fetch(`http://127.0.0.1:${tunnelPort}/envelope`, { method: 'POST', body: envelope });
expect(res.status).toBe(200);
};

return { sendEnvelope, received, upstreamPort, proxyPort, connects };
}

afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
servers.splice(0).map(server => {
server.closeAllConnections();
return new Promise<void>(resolve => server.close(() => resolve()));
}),
);
});

test('forwards envelopes directly when no proxy is set', async () => {
vi.stubEnv('http_proxy', '');
const { sendEnvelope, received, connects } = await setup();

await sendEnvelope();

await expect(received).resolves.toEqual({
url: expect.stringMatching(/^\/api\/1\/envelope\/\?sentry_version=7&sentry_key=public/),
body: expect.stringContaining('"type":"event"'),
});
expect(connects).toEqual([]);
});

test('forwards envelopes through http_proxy', async () => {
const { sendEnvelope, received, upstreamPort, proxyPort, connects } = await setup();
vi.stubEnv('http_proxy', `http://127.0.0.1:${proxyPort}`);

await sendEnvelope();

await expect(received).resolves.toEqual({
url: expect.stringMatching(/^\/api\/1\/envelope\//),
body: expect.stringContaining('"type":"event"'),
});
expect(connects).toEqual([`127.0.0.1:${upstreamPort}`]);
});

test('skips the proxy for hosts in no_proxy', async () => {
const { sendEnvelope, received, proxyPort, connects } = await setup();
vi.stubEnv('http_proxy', `http://127.0.0.1:${proxyPort}`);
vi.stubEnv('no_proxy', '127.0.0.1');

await sendEnvelope();

await expect(received).resolves.toEqual(expect.objectContaining({ url: expect.any(String) }));
expect(connects).toEqual([]);
});
});

describe('getSentryDSNFromEnv', () => {
afterEach(() => {
delete process.env.SENTRY_DSN;
Expand Down
3 changes: 2 additions & 1 deletion packages/aws-serverless/test/lambda-extension-index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Server } from 'node:http';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';

let extension: Awaited<ReturnType<typeof setupExtension>>;
Expand All @@ -17,7 +18,7 @@ afterEach(() => {
async function setupExtension() {
const { AwsLambdaExtension } = await import('../src/lambda-extension/aws-lambda-extension');
vi.spyOn(AwsLambdaExtension.prototype, 'register').mockResolvedValue();
vi.spyOn(AwsLambdaExtension.prototype, 'startSentryTunnel').mockImplementation(() => {});
vi.spyOn(AwsLambdaExtension.prototype, 'startSentryTunnel').mockReturnValue({} as Server);
const next = vi.spyOn(AwsLambdaExtension.prototype, 'next').mockImplementation(() => new Promise(() => {}));
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export { cron } from './cron';
export { NODE_VERSION } from './nodeVersion';
export { defaultStackParser, getSentryRelease } from './sdk/api';
export { makeNodeTransport } from './transports';
export { getProxyAgent as _INTERNAL_getProxyAgent } from './transports/http';
export { createGetModuleFromFilename } from './utils/module';

export { eveConversationHook, eveIntegration, getInstrumentedModuleNames } from '@sentry/server-utils';
Expand Down
29 changes: 19 additions & 10 deletions packages/node/src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,32 @@ export function makeNodeTransport(options: NodeTransportOptions): Transport {

const isHttps = urlSegments.protocol === 'https:';

// Proxy prioritization: http => `options.proxy` | `process.env.http_proxy`
// Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy`
const proxy = applyNoProxyOption(
urlSegments,
options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy,
);

const nativeHttpModule = isHttps ? https : http;
const keepAlive = options.keepAlive ?? true;
const agent = proxy
? (new HttpsProxyAgent(proxy) as http.Agent)
: new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 });
const agent =
getProxyAgent(urlSegments, options.proxy) ??
new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 });

const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent);
return createTransport(options, requestExecutor);
}

/**
* Returns an agent that tunnels requests to `url` through a proxy, or `undefined` when no proxy applies.
*
* Proxy prioritization: http => `proxy` | `process.env.http_proxy`
* Proxy prioritization: https => `proxy` | `process.env.https_proxy` | `process.env.http_proxy`
* Hosts listed in `process.env.no_proxy` are never proxied.
*/
export function getProxyAgent(url: URL, proxy?: string): http.Agent | undefined {
const isHttps = url.protocol === 'https:';
const resolvedProxy = applyNoProxyOption(
url,
proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy,
);
return resolvedProxy ? new HttpsProxyAgent(resolvedProxy) : undefined;
}

/**
* Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion.
*
Expand Down
Loading