Repository navigation
feat: Polish the public tunnel API and export it on Server SDKs #25783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3f952f2
a5fc7f8
bdf9e35
7ba896a
39e6f33
139d00e
f057ec8
b2dcf4c
f0f95f3
6a2c974
e32379d
32fdf4f
5e0a515
d3a9544
e80e999
c4850c7
1c77ce4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -164,6 +164,10 @@ export { | |
| tediousIntegration, | ||
| trpcMiddleware, | ||
| serveTunnelIntegration, | ||
| getTunnelPath, | ||
| handleTunnelRequest, | ||
| handleTunnelRequestIfMatched, | ||
| isTunnelRequest, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. m: what's the use case for exporting
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They can use it to match a request manually if they don't use |
||
| updateSpanName, | ||
| vercelAIIntegration, | ||
| withActiveSpan, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import * as Sentry from '../src'; | ||
| import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; | ||
|
|
||
| describe('managed tunnel exports', () => { | ||
| it.each([ | ||
| 'serveTunnelIntegration', | ||
| 'handleTunnelRequestIfMatched', | ||
| 'isTunnelRequest', | ||
| 'getTunnelPath', | ||
| 'handleTunnelRequest', | ||
| ])('exports %s for wiring the tunnel by hand', name => { | ||
| expect(typeof (Sentry as Record<string, unknown>)[name]).toBe('function'); | ||
| }); | ||
|
|
||
| it('exports the tunnel types', () => { | ||
| const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; | ||
| const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; | ||
| const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; | ||
|
|
||
| expect([tunnelOptions, request, handleOptions]).toHaveLength(3); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| import { defineIntegration } from '../integration'; | ||
| import type { IntegrationFn } from '../types/integration'; | ||
| import type { ServerTunnelOptions } from '../types/options'; | ||
| import { enableManagedTunnel, SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/managedTunnelDefaults'; | ||
| import { enableManagedTunnel } from '../utils/managedTunnelDefaults'; | ||
| import { SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/tunnel'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L and out of scope of the changes of his PR: The JSDoc of Here's a rough suggestion: /**
* Configures your Sentry server SDK to handle and forward requests from a Sentry browser SDK configured with `tunnel: true`.
*
* With this integration on the server and `tunnel: true` in the browser `Sentry.init`, the browser SDK sends its events to * your own server instead, and your server sends them on to Sentry.
*
* In meta frameworks instrumented with Sentry (e.g. NextJS, Nuxt or SvelteKit), this integration is already enabled. For standalone server applications (e.g. Express or Fastify), add this integration and call `handleTunnelRequestIfMatched` in your request handler.
*
* @example
* ```js
* // Server
* Sentry.init({
* dsn: '__DSN__',
* integrations: [Sentry.serveTunnelIntegration()],
* });
*
* // Browser
* Sentry.init({
* dsn: '__DSN__',
* tunnel: true,
* });
* ```
*/
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can do, will do a follow up PR for this. |
||
| import { forwardTunnelRequest } from '../utils/tunnelForward'; | ||
|
|
||
| const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import type { Client } from '../client'; | ||
| import type { Integration } from '../types/integration'; | ||
| import { getClient } from '../currentScopes'; | ||
| import { URL_PATH } from '@sentry/conventions/attributes'; | ||
| import type { DsnComponents, DsnLike } from '../types/dsn'; | ||
|
|
@@ -13,27 +14,68 @@ import { parseUrl } from './url'; | |
| export type TunnelForwarder = (request: Request, allowedDsns: Array<string>) => Promise<Response>; | ||
|
|
||
| /** | ||
| * A server client's tunnel paths, allowed DSNs and forwarder, computed once per client. | ||
| * A server client's tunnel paths, base path, allowed DSNs and forwarder, computed once per client. | ||
| */ | ||
| interface ServedTunnel { | ||
| paths: string[]; | ||
| allowedDsns: string[]; | ||
| interface ServedTunnel extends TunnelMatch { | ||
| forward: TunnelForwarder; | ||
| } | ||
|
|
||
| const servedTunnels = new WeakMap<Client, ServedTunnel>(); | ||
|
|
||
| /** | ||
| * Stores a server client's computed tunnel paths and allowed DSNs. | ||
| * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the | ||
| * integration, and with it the forwarder, into apps that don't use it. | ||
| */ | ||
| export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; | ||
|
|
||
| /** | ||
| * Computes a server client's served tunnel from its resolved options, and stores it. | ||
| */ | ||
| export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { | ||
| export function setServedTunnel( | ||
| client: Client, | ||
| tunnelOptions: ServerTunnelOptions, | ||
| forward: TunnelForwarder, | ||
| ): ServedTunnel { | ||
| const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn()); | ||
| const servedTunnel = { | ||
| paths: resolveTunnelPaths(tunnelOptions, allowedDsns), | ||
| basePath: tunnelOptions.basePath, | ||
| allowedDsns, | ||
| forward, | ||
| }; | ||
| servedTunnels.set(client, servedTunnel); | ||
|
|
||
| return servedTunnel; | ||
| } | ||
|
|
||
| /** | ||
| * The forwarder `serveTunnelIntegration` carries. Any copy of this module can reach it through the client. | ||
| */ | ||
| export function getServeTunnelForwarder(client: Client): TunnelForwarder | undefined { | ||
| return client.getIntegrationByName?.<Integration & { forward?: TunnelForwarder }>(SERVE_TUNNEL_INTEGRATION_NAME) | ||
| ?.forward; | ||
|
Comment on lines
+55
to
+56
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: I realize this isn't technically part of the PR but I was wondering why we put the forwarding function onto the integration? Would it be easier to store the forwarder directly on the client? Kinda have the same question for the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mostly bundle size, it's a server only feature so I didn't want it pulled into the client, which browser bundles share. Happy to change them if u think the placement is worth the bundle size.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah ok, I thought this would only affect ServerRuntimeClient. If we'd need to put it into the core client, I agree, then we should keep it out. |
||
| } | ||
|
|
||
| /** | ||
| * The active client's served tunnel. A copy of this module that didn't set it up rebuilds it once from what the | ||
| * client carries. Bundlers can give a framework's request hook its own copy, like Next.js's middleware next to its | ||
| * `instrumentation`. | ||
| */ | ||
| function getServedTunnel(): ServedTunnel | undefined { | ||
| const client = getClient(); | ||
| if (!client) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const servedTunnel = servedTunnels.get(client); | ||
| if (servedTunnel) { | ||
| return servedTunnel; | ||
| } | ||
|
|
||
| return client && servedTunnels.get(client); | ||
| const tunnelOptions = client.getOptions()._managedTunnel; | ||
| const forward = getServeTunnelForwarder(client); | ||
|
|
||
| return tunnelOptions && forward ? setServedTunnel(client, tunnelOptions, forward) : undefined; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -60,11 +102,11 @@ export function applyTunnelDefaults( | |
| } | ||
|
|
||
| /** | ||
| * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, | ||
| * and as a hash it gives ad-blocker filter lists nothing to match. | ||
| * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`, by default the active client's. Browser and server | ||
| * derive it independently, and as a hash it gives ad-blocker filter lists nothing to match. | ||
| */ | ||
| export function getTunnelPath(dsn: DsnLike): string | undefined { | ||
| const components = makeDsn(dsn); | ||
| export function getTunnelPath(dsn?: DsnLike): string | undefined { | ||
| const components = dsn ? makeDsn(dsn) : getClient()?.getDsn(); | ||
|
|
||
| return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined; | ||
| } | ||
|
|
@@ -76,7 +118,6 @@ export interface TunnelRequestLike { | |
| method?: string; | ||
| /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ | ||
| url?: string; | ||
| headers?: Headers | Record<string, string | string[] | undefined>; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -133,29 +174,67 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { | |
| } | ||
|
|
||
| /** | ||
| * Returns true if the request targets the active server client's managed tunnel. | ||
| * The paths, base path and allowed DSNs of a tunnel to match requests against. | ||
| */ | ||
| export function isTunnelRequest(request: TunnelRequestLike): boolean { | ||
| if (request.method !== 'POST' || !request.url) { | ||
| return false; | ||
| interface TunnelMatch { | ||
| paths: string[]; | ||
| basePath?: string; | ||
| allowedDsns: string[]; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the tunnel the public helpers match: `options`, else the one `serveTunnelIntegration` serves, else the | ||
| * path of the active client's own DSN. | ||
| */ | ||
| export function resolveTunnelMatch(options?: ServerTunnelOptions): TunnelMatch { | ||
| const servedTunnel = !options && getServedTunnel(); | ||
| if (servedTunnel) { | ||
| return servedTunnel; | ||
| } | ||
|
|
||
| const tunnelOptions = getClient()?.getOptions()._managedTunnel; | ||
| if (!tunnelOptions) { | ||
| const allowedDsns = resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn()); | ||
|
|
||
| return { paths: resolveTunnelPaths(options ?? {}, allowedDsns), basePath: options?.basePath, allowedDsns }; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if the request is a POST to one of the tunnel's paths. | ||
| */ | ||
| export function matchesTunnel(request: TunnelRequestLike, tunnel: Omit<TunnelMatch, 'allowedDsns'>): boolean { | ||
| if (request.method !== 'POST' || !request.url) { | ||
| return false; | ||
| } | ||
|
|
||
| const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); | ||
| const path = stripBasePath(parseUrl(request.url).path || '/', tunnel.basePath); | ||
|
|
||
| return path !== undefined && tunnel.paths.includes(path.replace(/\/$/, '')); | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if the request is a POST to the managed tunnel. The tunnel comes from `options`, else from | ||
| * `serveTunnelIntegration`, else it's the path of the active client's own DSN. | ||
| */ | ||
| export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunnelOptions): boolean { | ||
| return matchesTunnel(request, resolveTunnelMatch(options)); | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if the request targets the tunnel `serveTunnelIntegration` serves. Unlike {@link isTunnelRequest}, | ||
| * there's no fallback, so the framework SDKs serve nothing without the integration. | ||
| */ | ||
| export function isServedTunnelRequest(request: TunnelRequestLike): boolean { | ||
| const servedTunnel = getServedTunnel(); | ||
|
|
||
| return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); | ||
| return !!servedTunnel && matchesTunnel(request, servedTunnel); | ||
| } | ||
|
|
||
| /** | ||
| * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. | ||
| * Forwards the request if it targets the tunnel `serveTunnelIntegration` serves, otherwise returns `undefined`. The | ||
| * framework SDKs answer tunnel requests with it. | ||
| */ | ||
| export function handleTunnelRequestIfMatched(request: Request): Promise<Response> | undefined { | ||
| export function handleServedTunnelRequest(request: Request): Promise<Response> | undefined { | ||
| const servedTunnel = getServedTunnel(); | ||
| if (!servedTunnel?.allowedDsns.length || !isTunnelRequest(request)) { | ||
| if (!servedTunnel?.allowedDsns.length || !matchesTunnel(request, servedTunnel)) { | ||
| return undefined; | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.