Skip to content
Merged
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
15 changes: 15 additions & 0 deletions packages/web/src/server/attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,21 @@ describe.each([
expect(fetchMock).toHaveBeenCalledWith(override, expect.anything());
});

it('reports the url option instead of the referer', async () => {
const url = 'https://acme.org/welcome';

await fn(id, undefined, {
headers: { ...headers, referer: `${url}?email=jo%40acme.org` },
url,
});

expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(
fetchMock.mock.calls[0]?.[1]?.body as string,
) as Record<string, unknown>;
expect(body.o).toBe(url);
});

it('reports an error when no headers are available', async () => {
await fn(id);

Expand Down
6 changes: 4 additions & 2 deletions packages/web/src/server/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ async function trackProfile(
* @param userId - The identifier of the user.
* @param [traits] - Additional traits of the user. Nested objects are not supported. Allowed values are `string`, `number`, `boolean`, and `null`.
* @param [options] - Pass `request` or `headers` when the function runs
* outside of a Vercel Function, where no request context is available.
* outside of a Vercel Function, where no request context is available, and
* `url` to report another page URL than the one of the current request.
*/
export async function identify(
userId: string,
Expand All @@ -128,7 +129,8 @@ export async function identify(
* @param groupId - The identifier of the group.
* @param [traits] - Additional traits of the group. Nested objects are not supported. Allowed values are `string`, `number`, `boolean`, and `null`.
* @param [options] - Pass `request` or `headers` when the function runs
* outside of a Vercel Function, where no request context is available.
* outside of a Vercel Function, where no request context is available, and
* `url` to report another page URL than the one of the current request.
*/
export async function group(
groupId: string,
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/server/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface ExposureOptions extends Omit<Options, 'flags'>, Attribution {}
* @param [options.props] - Traits of the user and group, like `plan` or `role`.
* @param [options.request] / [options.headers] - Pass them when the function runs
* outside of a Vercel Function, where no request context is available.
* @param [options.url] - The page URL the exposure is reported for. Defaults to the URL of the current request.
*/
export async function trackExposure(
input: ServerExposureInput,
Expand Down
49 changes: 49 additions & 0 deletions packages/web/src/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,38 @@ describe('server track', () => {
);
});

describe('given a url option', () => {
it('reports it instead of the referer', async () => {
const url = 'https://acme.org/callback';

await track('login', undefined, {
headers: { ...headers, referer: `${url}?token=secret` },
url,
});

expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(
fetchMock.mock.calls[0]?.[1]?.body as string,
) as Record<string, unknown>;
expect(body.o).toBe(url);
});

it.each([
'/relative/path',
'not a url',
'ftp://acme.org/file',
'',
])('does not send when the url is invalid (%j)', async (url) => {
await track('login', undefined, { headers, url });

expect(fetchMock).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError.mock.calls[0]?.[0]).toMatchObject({
message: expect.stringContaining('Invalid `url` option'),
});
});
});

it('inclues the provided bypass secret', async () => {
const bypassSecret = 'secretXYZ';
process.env.VERCEL_AUTOMATION_BYPASS_SECRET = bypassSecret;
Expand Down Expand Up @@ -686,6 +718,23 @@ describe('server track', () => {
);
});

it('reports the url option instead of the request url', async () => {
const url = 'https://acme.org/callback';
requestContext = {
headers: { ...headers, referer: `${url}?token=from-referer` },
url: `${url}?token=from-context&code=123`,
};

await track('login', undefined, { url });

expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(
fetchMock.mock.calls[0]?.[1]?.body as string,
) as Record<string, unknown>;
expect(body.o).toBe(url);
expect(JSON.stringify(body)).not.toContain('token=');
});

it('uses waitUntil', async () => {
const waitUntil = vi.fn();
requestContext = { headers, waitUntil };
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface TrackOptions extends Options, Attribution {}
* @param [options.groupId] - The group the event is attributed to.
* @param [options.props] - Traits of the user and group, like `plan` or `role`. Same constraints as `properties`.
* @param [options.request] / [options.headers] - Pass them when the function runs outside of a Vercel Function, where no request context is available.
* @param [options.url] - The page URL the event is reported for. Defaults to the URL of the current request.
*/
export async function track(
eventName: string,
Expand Down
49 changes: 45 additions & 4 deletions packages/web/src/server/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface Options {
flags?: FlagsDataInput;
headers?: AllowedHeaders;
request?: { headers: AllowedHeaders };
/**
* The page URL the event is reported for. Overrides the URL of the current
* request, for example to strip a token from its query. Must be an absolute
* `http(s)` URL; the event is not sent otherwise.
*/
url?: string;
}

export interface RequestContext {
Expand Down Expand Up @@ -125,6 +131,44 @@ export function resolveHeaders(
return { requestHeaders, hasHeaders: Boolean(headers) };
}

/**
* The URL reported in the `o` field of a payload. An explicit `url` wins over
* the URL of the incoming request, then its referer, then the endpoint origin.
*
* @throws when an explicit `url` is not an absolute `http(s)` URL: falling back
* to the request URL would send the value the caller wanted to replace.
*/
export function resolveUrl(
url: string | undefined,
requestContext: ResolvedRequestContext,
requestHeaders: HeadersObject,
endpoint: string,
): string {
if (url !== undefined) {
let parsed: URL | undefined;
try {
parsed = new URL(url);
} catch {
/* reported below */
}
if (
!parsed ||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
) {
throw new Error(
`Invalid \`url\` option: expected an absolute http(s) URL, got "${url}".`,
);
}
return url;
}

return (
requestContext?.url ||
(requestHeaders.referer as string) ||
new URL(endpoint).origin
);
}

/** Ingestion routes a server sender can post to. */
export type EndpointKind = 'event' | 'exposure' | 'identify' | 'group';

Expand Down Expand Up @@ -202,10 +246,7 @@ export async function dispatch({
}

const body = {
o:
requestContext?.url ||
(requestHeaders.referer as string) ||
new URL(endpoint).origin,
o: resolveUrl(options?.url, requestContext, requestHeaders, endpoint),
ts: Date.now(),
sdkn: `${packageName}/server`,
sdkv: version,
Expand Down
Loading