diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 3c880974fe0b..fcef9bfc930b 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -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', ]; diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index 163054aae0b3..86c7a03232cc 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -1,4 +1,5 @@ import * as http from 'node:http'; +import * as https from 'node:https'; import { buffer } from 'node:stream/consumers'; import { consoleSandbox, @@ -8,6 +9,7 @@ import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn, } from '@sentry/core'; +import { _INTERNAL_getProxyAgent as getProxyAgent } from '@sentry/node'; import { DEBUG_BUILD } from './debug-build'; /** @@ -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) { @@ -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; @@ -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({})); @@ -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`. * diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts index f96d211d2e53..3cf2045e6115 100644 --- a/packages/aws-serverless/test/aws-lambda-extension.test.ts +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -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'; @@ -89,6 +90,116 @@ describe('AwsLambdaExtension.next', () => { }); }); +describe('AwsLambdaExtension.startSentryTunnel', () => { + const servers: Array = []; + + async function listen(server: http.Server): Promise { + 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; + 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 => { + 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(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; diff --git a/packages/aws-serverless/test/lambda-extension-index.test.ts b/packages/aws-serverless/test/lambda-extension-index.test.ts index 76c813538ebb..7ed050a2e826 100644 --- a/packages/aws-serverless/test/lambda-extension-index.test.ts +++ b/packages/aws-serverless/test/lambda-extension-index.test.ts @@ -1,3 +1,4 @@ +import type { Server } from 'node:http'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; let extension: Awaited>; @@ -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); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 27c162b96f00..eb683a87ae88 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -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'; diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index a6055fb1167e..66fc0f080b03 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -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. *