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
156 changes: 151 additions & 5 deletions packages/web/src/generic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { inject, track } from './generic';
import { group, identify, inject, pageview, track } from './generic';
import { withCdp } from './test-utils';
import type { AllowedPropertyValues, Mode } from './types';

const cdpEnvelope = {
schemaVersion: 1,
event: {
type: 'track',
eventId: '018f4f7e-7b8a-7c9d-a012-3456789abcde',
context: {
attribution: { touches: [{ source: 'search', campaign: null }] },
},
},
routing: { sendTo: { web_analytics: true } },
};

describe.each([
{
mode: 'development',
Expand Down Expand Up @@ -149,7 +162,7 @@ describe.each([
track('exposure', { variant: 'A' });
expect(window.vaq?.[0]).toEqual([
'event',
{ name: 'exposure', data: { variant: 'A' } },
{ name: 'exposure', data: { variant: 'A' }, options: {} },
]);
});
});
Expand All @@ -165,14 +178,14 @@ describe.each([
it('tracks event with name only', () => {
const name = 'my event';
track(name);
expect(window.vaq?.[0]).toEqual(['event', { name }]);
expect(window.vaq?.[0]).toEqual(['event', { name, options: {} }]);
});

it('allows custom data to be tracked', () => {
const name = 'custom event';
const data = { string: 'string', number: 1 };
track(name, data);
expect(window.vaq?.[0]).toEqual(['event', { name, data }]);
expect(window.vaq?.[0]).toEqual(['event', { name, data, options: {} }]);
});

it('should strip data for nested objects', () => {
Expand All @@ -188,9 +201,142 @@ describe.each([
if (mode === 'development') {
expect(console.error).toHaveBeenCalledTimes(1);
} else {
expect(window.vaq?.[0]).toEqual(['event', { name, data }]);
expect(window.vaq?.[0]).toEqual([
'event',
{ name, data, options: {} },
]);
}
});

it('lifts __cdp out of options into the command payload', () => {
const name = 'custom event';
const data = { string: 'string' };
const flags = ['my-flag'];
track(name, data, withCdp({ flags, __cdp: cdpEnvelope }));
expect(window.vaq?.[0]).toEqual([
'event',
{ name, data, options: { flags }, __cdp: cdpEnvelope },
]);
});

it('sends __cdp without properties', () => {
const name = 'custom event';
track(
name,
undefined,
withCdp<{ flags?: string[] }>({ __cdp: cdpEnvelope }),
);
expect(window.vaq?.[0]).toEqual([
'event',
{ name, options: {}, __cdp: cdpEnvelope },
]);
});
});
});

describe('pageview', () => {
beforeEach(() => {
window.vaq = [];
inject({ mode });
});

it('omits __cdp when not provided', () => {
pageview({ route: '/blog/[slug]', path: '/blog/hello' });
expect(window.vaq?.[0]).toEqual([
'pageview',
{ route: '/blog/[slug]', path: '/blog/hello' },
]);
});

it('forwards __cdp', () => {
pageview(
withCdp({
route: '/blog/[slug]',
path: '/blog/hello',
__cdp: cdpEnvelope,
}),
);
expect(window.vaq?.[0]).toEqual([
'pageview',
{ route: '/blog/[slug]', path: '/blog/hello', __cdp: cdpEnvelope },
]);
});
});

describe.each([
{
fn: identify,
command: 'identify',
idKey: 'userId',
id: 'user_123',
},
{
fn: group,
command: 'group',
idKey: 'groupId',
id: 'team_456',
},
])('$command', ({ fn, command, idKey, id }) => {
beforeEach(() => {
window.va = undefined;
window.vaq = undefined;
});

it('queues the call even before inject', () => {
fn(id);
expect(window.vaq?.[0]).toEqual([command, { [idKey]: id }]);
});

it('sends flat traits, including null', () => {
inject({ mode });
const traits = { plan: 'pro', seats: 12, active: true, churned: null };
fn(id, traits);
expect(window.vaq?.[0]).toEqual([command, { [idKey]: id, traits }]);
});

it('forwards __cdp', () => {
inject({ mode });
(fn as (...args: unknown[]) => void)(
id,
{ plan: 'pro' },
{
__cdp: cdpEnvelope,
},
);
expect(window.vaq?.[0]).toEqual([
command,
{ [idKey]: id, traits: { plan: 'pro' }, __cdp: cdpEnvelope },
]);
});

// The ingestion endpoint only accepts primitive trait values, like it does
// for custom event properties. Nested data belongs in `__cdp`.
it('rejects nested traits like track does with properties', () => {
const consoleError = vi
.spyOn(global.console, 'error')
.mockImplementation(() => void 0);
consoleError.mockClear();
inject({ mode });
fn(id, {
plan: 'pro',
address: { city: 'Paris' } as unknown as AllowedPropertyValues,
tags: ['a', 'b'] as unknown as AllowedPropertyValues,
});

if (mode === 'development') {
expect(consoleError).toHaveBeenCalledWith(
new Error(
'The following properties are not valid: address, tags. Only strings, numbers, booleans, and null are allowed.',
),
);
expect(window.vaq).toBeUndefined();
} else {
expect(consoleError).not.toHaveBeenCalled();
expect(window.vaq?.[0]).toEqual([
command,
{ [idKey]: id, traits: { plan: 'pro' } },
]);
}
});
});
});
133 changes: 108 additions & 25 deletions packages/web/src/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
BeforeSendEvent,
FlagsDataInput,
InjectProps,
InternalOptions,
} from './types';
import {
computeRoute,
Expand Down Expand Up @@ -64,29 +65,50 @@ function inject(
document.head.appendChild(script);
}

/**
* The browser senders must never run on the server. Throws in development so
* the mistake is loud, and warns in production so the caller can bail out.
*
* @returns `true` when the caller must return early.
*/
function rejectServerRuntime(fnName: 'track' | 'identify' | 'group'): boolean {
if (isBrowser()) {
return false;
}

const msg = `[Vercel Web Analytics] Please import \`${fnName}\` from \`@vercel/analytics/server\` when using this function in a server environment`;

if (isProduction()) {
console.warn(msg);
} else {
throw new Error(msg);
}

return true;
}

// The public signatures below are overloads: only they end up in the type
// declarations. The implementation signatures also accept `InternalOptions`,
// which stay a runtime-only contract with Vercel's CDP.

/**
* Tracks a custom event. Please refer to the [documentation](https://vercel.com/docs/concepts/analytics/custom-events) for more information on custom events.
* @param name - The name of the event.
* * Examples: `Purchase`, `Click Button`, or `Play Video`.
* @param [properties] - Additional properties of the event. Nested objects are not supported. Allowed values are `string`, `number`, `boolean`, and `null`.
* @param [options.flags] - Feature flags to attach to the event.
*/
function track(
name: string,
properties?: Record<string, AllowedPropertyValues>,
options?: {
flags?: FlagsDataInput;
},
options?: { flags?: FlagsDataInput },
): void;
function track(
name: string,
properties?: Record<string, AllowedPropertyValues>,
{ __cdp, ...options }: { flags?: FlagsDataInput } & InternalOptions = {},
): void {
if (!isBrowser()) {
const msg =
'[Vercel Web Analytics] Please import `track` from `@vercel/analytics/server` when using this function in a server environment';

if (isProduction()) {
console.warn(msg);
} else {
throw new Error(msg);
}

if (rejectServerRuntime('track')) {
return;
}
// in case the track function is invoked even before inject
Expand All @@ -95,7 +117,7 @@ function track(
initQueue();

if (!properties) {
window.va?.('event', { name, options });
window.va?.('event', { name, options, __cdp });
return;
}

Expand All @@ -104,30 +126,91 @@ function track(
strip: isProduction(),
});

window.va?.('event', {
name,
data: props,
options,
});
window.va?.('event', { name, data: props, options, __cdp });
} catch (err) {
if (err instanceof Error && isDevelopment()) {
console.error(err);
}
}
}

function pageview(input: { route?: string | null; path?: string }): void;
function pageview({
route,
path,
}: {
route?: string | null;
path?: string;
}): void {
window.va?.('pageview', { route, path });
__cdp,
}: { route?: string | null; path?: string } & InternalOptions): void {
window.va?.('pageview', { route, path, __cdp });
}

/**
* Sends a profile mutation (`identify` or `group`) to the script. Traits go
* through the same validation as custom event properties: nested values are
* stripped in production and reported in development.
*/
function trackProfile(
name: 'identify' | 'group',
idKey: 'userId' | 'groupId',
id: string,
traits: Record<string, AllowedPropertyValues> | undefined,
{ __cdp }: InternalOptions = {},
): void {
if (rejectServerRuntime(name)) {
return;
}
initQueue();

try {
window.va?.(name, {
[idKey]: id,
traits: parseProperties(traits, {
strip: isProduction(),
}),
__cdp,
});
} catch (err) {
if (err instanceof Error && isDevelopment()) {
console.error(err);
}
}
}

/**
* Associates the current visitor with a user.
* @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`.
*/
function identify(
userId: string,
traits?: Record<string, AllowedPropertyValues>,
): void;
function identify(
userId: string,
traits?: Record<string, AllowedPropertyValues>,
options?: InternalOptions,
): void {
trackProfile('identify', 'userId', userId, traits, options);
}

/**
* Associates the current visitor with a group, like a team or an organization.
* @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`.
*/
function group(
groupId: string,
traits?: Record<string, AllowedPropertyValues>,
): void;
function group(
groupId: string,
traits?: Record<string, AllowedPropertyValues>,
options?: InternalOptions,
): void {
trackProfile('group', 'groupId', groupId, traits, options);
}

export { inject, track, pageview, computeRoute };
export type { AnalyticsProps, BeforeSend, BeforeSendEvent };
export { computeRoute, group, identify, inject, pageview, track };

export default {
inject,
Expand Down
Loading
Loading