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

Expand Down Expand Up @@ -339,4 +339,28 @@ describe.each([
}
});
});

describe('reset', () => {
beforeEach(() => {
window.va = undefined;
window.vaq = undefined;
});

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

it('queues the call after identify and group', () => {
inject({ mode });
identify('user_123');
group('team_456');
reset();
expect(window.vaq?.map(([command]) => command)).toEqual([
'identify',
'group',
'reset',
]);
});
});
});
18 changes: 16 additions & 2 deletions packages/web/src/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ function inject(
*
* @returns `true` when the caller must return early.
*/
function rejectServerRuntime(fnName: 'track' | 'identify' | 'group'): boolean {
function rejectServerRuntime(
fnName: 'track' | 'identify' | 'group' | 'reset',
): boolean {
if (isBrowser()) {
return false;
}
Expand Down Expand Up @@ -209,8 +211,20 @@ function group(
trackProfile('group', 'groupId', groupId, traits, options);
}

/**
* Forgets the user and group set by `identify()` and `group()`. Call it when
* the visitor logs out, so later events are no longer attributed to them.
*/
function reset(): void {
if (rejectServerRuntime('reset')) {
return;
}
initQueue();
window.va?.('reset');
}

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

export default {
inject,
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/nextjs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';
import React, { type ReactNode, Suspense } from 'react';
import { track } from '../generic';
import { group, identify, reset, track } from '../generic';
import { Analytics as AnalyticsScript } from '../react';
import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types';
import { getBasePath, getConfigString, useRoute } from './utils';
Expand Down Expand Up @@ -30,5 +30,5 @@ export function Analytics(props: Props): null {
) as never;
}

export { track };
export type { AnalyticsProps, BeforeSend, BeforeSendEvent };
export { group, identify, reset, track };
4 changes: 2 additions & 2 deletions packages/web/src/nuxt/runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { onNuxtReady, useRoute, useRouter } from 'nuxt/app';
import { inject, pageview, track } from '../../generic';
import { group, identify, inject, pageview, reset, track } from '../../generic';
import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../../types';
import { computeRoute, isBrowser } from '../../utils';
import { createComponent } from '../../vue/create-component';
Expand Down Expand Up @@ -41,4 +41,4 @@ function injectAnalytics(props: Omit<AnalyticsProps, 'framework'> = {}): void {
}
}

export { injectAnalytics, track };
export { group, identify, injectAnalytics, reset, track };
4 changes: 2 additions & 2 deletions packages/web/src/react/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { inject, pageview, track } from '../generic';
import { group, identify, inject, pageview, reset, track } from '../generic';
import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types';
import { getBasePath, getConfigString } from './utils';

Expand Down Expand Up @@ -65,5 +65,5 @@ function Analytics(
return null;
}

export { track, Analytics };
export type { AnalyticsProps, BeforeSend, BeforeSendEvent };
export { Analytics, group, identify, reset, track };
2 changes: 2 additions & 0 deletions packages/web/src/remix/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { group, identify, reset, track } from '../generic';
import { Analytics as AnalyticsScript } from '../react';
import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types';
import { getBasePath, getConfigString, useRoute } from './utils';
Expand All @@ -15,3 +16,4 @@ export function Analytics(props: Omit<AnalyticsProps, 'route'>): JSX.Element {
);
}
export type { AnalyticsProps, BeforeSend, BeforeSendEvent };
export { group, identify, reset, track };
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { name as packageName, version } from '../../package.json';
import { withCdp } from '../test-utils';
import { group, identify } from './profiles';
import { group, identify } from './attribution';

const sdkn = `${packageName}/server`;
const sdkv = version;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,53 @@ import {
resolveEndpoint,
} from './request';

/**
* Who an event is about: the `identify`/`group` profile senders, and the
* attribution fields every other sender attaches to its payload.
*
* In the browser, the script remembers the last `identify()` and `group()`
* calls and attaches them to every event. The server keeps no such state, so
* callers pass them explicitly when they know them.
*/

export interface Attribution {
/** The user the event is attributed to. */
userId?: string;
/** The group (team, organization...) the event is attributed to. */
groupId?: string;
/**
* Traits of the user and group at the time of the event, like `plan` or
* `role`. Nested objects are not supported. Allowed values are `string`,
* `number`, `boolean`, and `null`.
*/
props?: Record<string, AllowedPropertyValues>;
}

/**
* The attribution fields of a payload, validated and truncated like the
* browser script does before sending them. Call it before `dispatch()`, so
* invalid `props` throw in development like invalid event properties do.
*/
export function attributionPayload(
attribution: Attribution | undefined,
): Attribution {
// `parseProperties` throws on invalid input; it never returns the `Error`
// its signature mentions.
const props = parseProperties(attribution?.props, {
strip: isProduction(),
}) as Record<string, AllowedPropertyValues> | undefined;

return {
...(attribution?.userId !== undefined && {
userId: truncateString(attribution.userId),
}),
...(attribution?.groupId !== undefined && {
groupId: truncateString(attribution.groupId),
}),
...(props && Object.keys(props).length > 0 && { props }),
};
}

/** Profile mutations carry no flags: they describe who, not what happened. */
export type ProfileOptions = Omit<Options, 'flags'>;

Expand Down
8 changes: 2 additions & 6 deletions packages/web/src/server/experiments-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AllowedPropertyValues } from '../types';
import type { Attribution } from './attribution';

/**
* Which unit an exposure applies to.
Expand Down Expand Up @@ -39,8 +39,4 @@ export interface ExposureInput {
* The browser runtime reads `userId`, `groupId` and `props` from persisted
* attribution state, which does not exist on the server. Pass them explicitly.
*/
export interface ServerExposureInput extends ExposureInput {
userId?: string;
groupId?: string;
props?: Record<string, AllowedPropertyValues>;
}
export interface ServerExposureInput extends ExposureInput, Attribution {}
44 changes: 44 additions & 0 deletions packages/web/src/server/experiments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,50 @@ describe('trackExposure', () => {
);
});

it('includes attribution passed in options, like track()', async () => {
await trackExposure(exposure, {
headers,
userId: 'user_123',
groupId: 'acme',
props: { plan: 'pro' },
});

const body = JSON.parse(
(fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)
?.body as string,
) as Record<string, unknown>;

expect(body).toMatchObject({
userId: 'user_123',
groupId: 'acme',
props: { plan: 'pro' },
en: exposure.experimentId,
});
});

it('prefers attribution from options over the input, field by field', async () => {
await trackExposure(
{
...exposure,
userId: 'input_user',
groupId: 'input_group',
props: { plan: 'free' },
},
{ headers, userId: 'options_user', props: { plan: 'pro' } },
);

const body = JSON.parse(
(fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)
?.body as string,
) as Record<string, unknown>;

expect(body).toMatchObject({
userId: 'options_user',
groupId: 'input_group',
props: { plan: 'pro' },
});
});

it('truncates long userId and groupId', async () => {
const long = 'a'.repeat(300);
await trackExposure(
Expand Down
33 changes: 18 additions & 15 deletions packages/web/src/server/experiments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { InternalOptions } from '../types';
import { isProduction, parseProperties, truncateString } from '../utils';
import { type Attribution, attributionPayload } from './attribution';
import type { ServerExposureInput } from './experiments-types';
import {
dispatch,
Expand All @@ -9,16 +9,23 @@ import {
resolveEndpoint,
} from './request';

/** Exposures carry their own attribution, so flags do not apply to them. */
export type ExposureOptions = Omit<Options, 'flags'>;
/**
* Exposures carry their own attribution, so flags do not apply to them.
* Attribution can be passed here, like for `track()`, and takes precedence
* over the same fields on the exposure input.
*/
export interface ExposureOptions extends Omit<Options, 'flags'>, Attribution {}

/**
* Reports that a unit was exposed to an experiment variant. Server-side only.
*
* @experimental
* @param input - The exposure to report, plus the `userId`, `groupId` and
* `props` attribution you want to attach to it.
* @param [options] - Pass `request` or `headers` when the function runs
* @param input - The exposure to report. It can carry the `userId`, `groupId`
* and `props` attribution too.
* @param [options.userId] - The user the exposure is attributed to.
* @param [options.groupId] - The group the exposure is attributed to.
* @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.
*/
export async function trackExposure(
Expand All @@ -34,8 +41,10 @@ export async function trackExposure(
}

const endpoint = resolveEndpoint('exposure');
const props = parseProperties(input.props, {
strip: isProduction(),
const attribution = attributionPayload({
userId: options?.userId ?? input.userId,
groupId: options?.groupId ?? input.groupId,
props: options?.props ?? input.props,
});

const data = {
Expand Down Expand Up @@ -64,13 +73,7 @@ export async function trackExposure(
fnName: 'trackExposure',
options,
payload: () => ({
...(input.userId !== undefined && {
userId: truncateString(input.userId),
}),
...(input.groupId !== undefined && {
groupId: truncateString(input.groupId),
}),
...(props && Object.keys(props).length > 0 && { props }),
...attribution,
en: input.experimentId,
ed: data,
}),
Expand Down
Loading
Loading