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';