From 49ef96c445be3ea9f1159c3961fa6b15a1a03638 Mon Sep 17 00:00:00 2001 From: Aaron Buxbaum Date: Fri, 14 Aug 2026 15:45:59 -0400 Subject: [PATCH] feat(server): add `beforeSend` to server-side `track` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server SDK resolves the event's page URL itself — from the Vercel request context, falling back to the `Referer` header — so a caller has no way to edit or withhold it. Apps whose URLs are themselves credentials (a one-time token in the path: signing links, password resets, magic links) therefore report a live secret with every custom event, and no amount of care at the call site can prevent it. Accept the client SDK's existing `beforeSend` contract as a `track` option. It receives `{ type: 'event', url }` and returns the event to send, or `null` to drop it. It is deliberately fail-closed: the hook runs before the request body is built, so returning `null` sends nothing at all, and a hook that throws is caught by the existing handler rather than falling through to the un-edited URL. The missing-headers check moves above the hook so the hook is not invoked for an event that was never going to be sent. `BeforeSend` and `BeforeSendEvent` are re-exported from `@vercel/analytics/server` so the hook can be typed without reaching into the root entry point. --- packages/web/src/server/index.test.ts | 107 ++++++++++++++++++++++++++ packages/web/src/server/index.ts | 35 ++++++--- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/packages/web/src/server/index.test.ts b/packages/web/src/server/index.test.ts index e063e98..49a33a7 100644 --- a/packages/web/src/server/index.test.ts +++ b/packages/web/src/server/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { name as packageName, version } from '../../package.json'; +import type { BeforeSendEvent } from '../types'; import { track } from './index'; // @vitest-environment node @@ -403,6 +404,77 @@ describe('server track', () => { ); }); + describe('given a beforeSend hook', () => { + const tokenUrl = 'https://example.com/invite/s3cret'; + const tokenHeaders = { ...headers, referer: tokenUrl }; + + it('sends the url the hook returns', async () => { + const name = 'invite_accepted'; + const redacted = 'https://example.com/invite/[token]'; + const beforeSend = vi.fn( + (event: BeforeSendEvent): BeforeSendEvent => ({ + ...event, + url: redacted, + }), + ); + + await track(name, undefined, { headers: tokenHeaders, beforeSend }); + + expect(beforeSend).toHaveBeenCalledWith({ + type: 'event', + url: tokenUrl, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/event`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + o: redacted, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + }), + }), + ); + }); + + it('sends nothing when the hook returns null', async () => { + await track('invite_accepted', undefined, { + headers: tokenHeaders, + beforeSend: () => null, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('sends nothing when the hook throws', async () => { + const error = new Error('bad hook'); + + await track('invite_accepted', undefined, { + headers: tokenHeaders, + beforeSend: () => { + throw error; + }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith(error); + }); + + it('is not called when no headers are available', async () => { + const beforeSend = vi.fn((event: BeforeSendEvent) => event); + + await track('test', undefined, { beforeSend }); + + expect(beforeSend).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + it('throws error when no headers are available', async () => { await track('test'); @@ -509,6 +581,41 @@ describe('server track', () => { ); }); + it('passes the request context url to beforeSend', async () => { + const contextUrl = `https://${appDomain}/invite/s3cret`; + const redacted = `https://${appDomain}/invite/[token]`; + requestContext = { headers, url: contextUrl }; + const beforeSend = vi.fn( + (event: BeforeSendEvent): BeforeSendEvent => ({ + ...event, + url: redacted, + }), + ); + + const name = 'invite_accepted'; + await track(name, undefined, { beforeSend }); + + expect(beforeSend).toHaveBeenCalledWith({ + type: 'event', + url: contextUrl, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/event`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + o: redacted, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + }), + }), + ); + }); + it('picks flags values from request context', async () => { const name = 'event-with-flags'; const flags = { feature1: true, feature2: 'enabled' }; diff --git a/packages/web/src/server/index.ts b/packages/web/src/server/index.ts index f540871..fd5fa59 100644 --- a/packages/web/src/server/index.ts +++ b/packages/web/src/server/index.ts @@ -1,6 +1,7 @@ import { name as packageName, version } from '../../package.json'; import type { AllowedPropertyValues, + BeforeSend, FlagsDataInput, PlainFlags, } from '../types'; @@ -15,6 +16,8 @@ function isHeaders(headers?: AllowedHeaders): headers is Headers { } interface Options { + /** A middleware function to modify the event before it is sent. Should return the event object or `null` to cancel the event. */ + beforeSend?: BeforeSend; flags?: FlagsDataInput; headers?: AllowedHeaders; request?: { headers: AllowedHeaders }; @@ -102,8 +105,28 @@ export async function track( ? ENDPOINT : new URL('/_vercel/insights/event', `https://${ENDPOINT}`).toString(); + const hasHeaders = Boolean(headers); + + if (!hasHeaders) { + throw new Error( + 'No session context found. Pass `request` or `headers` to the `track` function.', + ); + } + + let pageUrl = + requestContext?.url || (tmp.referer as string) || new URL(url).origin; + + // runs before the body is built, so returning null sends nothing at all + if (options?.beforeSend) { + const event = options.beforeSend({ type: 'event', url: pageUrl }); + if (!event) { + return; + } + pageUrl = event.url; + } + const body = { - o: requestContext?.url || (tmp.referer as string) || new URL(url).origin, + o: pageUrl, ts: Date.now(), sdkn: `${packageName}/server`, sdkv: version, @@ -113,14 +136,6 @@ export async function track( f: safeGetFlags(options?.flags, requestContext), }; - const hasHeaders = Boolean(headers); - - if (!hasHeaders) { - throw new Error( - 'No session context found. Pass `request` or `headers` to the `track` function.', - ); - } - const promise = fetch(url, { headers: { 'content-type': 'application/json', @@ -198,3 +213,5 @@ function safeGetFlags( /* empty */ } } + +export type { BeforeSend, BeforeSendEvent } from '../types';