Skip to content
Open
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
107 changes: 107 additions & 0 deletions packages/web/src/server/index.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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' };
Expand Down
35 changes: 26 additions & 9 deletions packages/web/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { name as packageName, version } from '../../package.json';
import type {
AllowedPropertyValues,
BeforeSend,
FlagsDataInput,
PlainFlags,
} from '../types';
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -198,3 +213,5 @@ function safeGetFlags(
/* empty */
}
}

export type { BeforeSend, BeforeSendEvent } from '../types';