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
10 changes: 7 additions & 3 deletions packages/aws-serverless/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core';
import { resolveServerTunnelOption } from '@sentry/core/server';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
import { awsIntegration } from '@sentry/server-utils';
Expand Down Expand Up @@ -76,7 +77,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
// 2. Environment variable SENTRY_LAYER_EXTENSION (if set)
// 3. Default logic based on sdkSource, tunnel, and proxy settings
const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true });
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere;
const defaultUseLayerExtension =
sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere;
const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension;

const opts = {
Expand All @@ -88,8 +90,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
if (opts.useLayerExtension) {
if (sdkSource !== 'aws-lambda-layer') {
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
} else if (opts.tunnel || proxyWouldInterfere) {
if (opts.tunnel) {
} else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) {
if (typeof opts.tunnel === 'string') {
Comment thread
cursor[bot] marked this conversation as resolved.
DEBUG_BUILD &&
debug.warn(
`Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`,
Expand All @@ -104,6 +106,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
}
} else {
DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension');
// The extension takes over the transport, but `tunnel: true` still has to serve the managed tunnel.
opts._managedTunnel = resolveServerTunnelOption(opts.tunnel)._managedTunnel;
opts.tunnel = 'http://localhost:9000/envelope';
}
}
Expand Down
13 changes: 13 additions & 0 deletions packages/aws-serverless/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ describe('init', () => {
);
});

test('keeps the managed tunnel when the Lambda extension takes over the transport', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');

init({ tunnel: true, useLayerExtension: true });

expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
expect.objectContaining({
tunnel: 'http://localhost:9000/envelope',
_managedTunnel: {},
}),
);
});

test('should not set tunnel when Lambda extension is disabled', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');
const options: AwsServerlessOptions = {
Expand Down
9 changes: 8 additions & 1 deletion packages/browser/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers';
import { makeFetchTransport } from './transports/fetch';
import { normalizeStringifyValue } from './normalizeStringifyValue';
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';
import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from './utils/managedTunnel';

/** Get the default integrations for the browser SDK. */
export function getDefaultIntegrations(_options: Options): Integration[] {
Expand Down Expand Up @@ -115,12 +116,18 @@ export function init(options: BrowserOptions = {}): Client | undefined {
defaultIntegrations,
});

const tunnelOptions = resolveBrowserTunnelOption(options);
const transport = options.transport || makeFetchTransport;

const clientOptions: BrowserClientOptions = {
...options,
...tunnelOptions,
enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled,
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations,
transport: options.transport || makeFetchTransport,
transport: tunnelOptions._managedTunnel
? makeManagedTunnelTransport(transport, options.transportOptions?.headers)
: transport,
};

setNormalizeStringifier(normalizeStringifyValue);
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export function makeFetchTransport(
headers: {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
// Tells a managed tunnel's own responses apart from whatever else answers its path.
'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'),
},
};
} catch (e) {
Expand Down
124 changes: 124 additions & 0 deletions packages/browser/src/utils/managedTunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core';
import {
debug,
getEnvelopeEndpointWithUrlEncodedAuth,
getTunnelPath,
makeDsn,
normalizeTunnelBasePath,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import type { BrowserOptions } from '../client';
import type { BrowserTransportOptions } from '../transports/types';

/**
* Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin
* path the server SDK derives from the same DSN.
*/
export function resolveBrowserTunnelOption(
options: BrowserOptions,
): Pick<BrowserOptions, 'transportOptions' | '_managedTunnel'> & { tunnel: string | undefined } {
const { tunnel, dsn, transportOptions } = options;
if (typeof tunnel === 'string' || !tunnel) {
return { tunnel, transportOptions };
}

const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel;
const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn));
if (!tunnelPath) {
return { tunnel: undefined, transportOptions };
}

const basePath = normalizeTunnelBasePath(tunnelOptions.basePath);
const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`;

DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`);

return {
tunnel: resolvedTunnel,
// Lets the client explain a tunnel request that nothing on the server answers.
_managedTunnel: tunnelOptions,
// The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no
// preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission.
transportOptions: {
...transportOptions,
headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers },
},
};
}
Comment thread
logaretm marked this conversation as resolved.

let hasWarnedAboutRejectedDsn = false;

/**
* Sends to Sentry directly once it's clear nothing on this origin serves the managed tunnel, e.g. in a static build,
* on an SPA host, or when the server SDK isn't set up. An envelope is only sent again when the tunnel's response
* proves it was never ingested, so nothing reaches Sentry twice.
*/
export function makeManagedTunnelTransport(
makeTransport: (options: BrowserTransportOptions) => Transport,
directHeaders: BrowserTransportOptions['headers'],
): (options: BrowserTransportOptions) => Transport {
return options => {
const tunnelTransport = makeTransport(options);
let directTransport: Transport | undefined;

const switchToDirect = (envelope: Envelope, reason: string): Transport | undefined => {
const { dsn, sdk } = envelope[0];
const components = dsn && makeDsn(dsn);
if (components && !directTransport) {
DEBUG_BUILD && debug.warn(`Sending to Sentry directly: ${reason}`);
directTransport = makeTransport({
...options,
url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk),
headers: directHeaders,
});
}

return directTransport;
};

return {
async send(envelope: Envelope): Promise<TransportMakeRequestResponse> {
if (directTransport) {
return directTransport.send(envelope);
}

const response = await tunnelTransport.send(envelope);
const { statusCode } = response;

// The tunnel handler answered, so whatever status it passes on is Sentry's.
if (response.headers?.['x-sentry-tunnel']) {
if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) {
hasWarnedAboutRejectedDsn = true;
debug.warn(
`The managed tunnel at "${options.url}" rejected this DSN, so events sent through it are lost. Add it to the server's \`allowedDsns\`.`,
);
}

return response;
}

// Something other than the tunnel handler rejected the request, e.g. nothing serves the path (404) or an auth
// middleware answered first (401). Only the handler forwards, so the envelope never reached Sentry and is safe
// to send once more.
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
const transport = switchToDirect(envelope, `"${options.url}" answered ${statusCode} instead of the tunnel.`);

return transport ? transport.send(envelope) : response;
}

// Something other than the tunnel handler answered, like a static host serving `index.html`. This envelope
// may have been delivered all the same, so it isn't sent again, only the ones after it go directly.
if (statusCode !== undefined && statusCode >= 200 && statusCode < 300) {
switchToDirect(envelope, `the response from "${options.url}" didn't come from a Sentry tunnel handler.`);
}

return response;
},
async flush(timeout?: number): Promise<boolean> {
const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]);

return results.every(Boolean);
},
};
};
}
3 changes: 2 additions & 1 deletion packages/browser/test/transports/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ describe('fetchTransport', () => {
expect(headers.get).toHaveBeenCalledTimes(0);
await transport.send(ERROR_ENVELOPE);

expect(headers.get).toHaveBeenCalledTimes(2);
expect(headers.get).toHaveBeenCalledTimes(3);
expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits');
expect(headers.get).toHaveBeenCalledWith('Retry-After');
expect(headers.get).toHaveBeenCalledWith('X-Sentry-Tunnel');
});

it('allows for custom options to be passed in', async () => {
Expand Down
Loading
Loading