diff --git a/packages/web/src/generic.test.ts b/packages/web/src/generic.test.ts index cc510d5..ea72d1b 100644 --- a/packages/web/src/generic.test.ts +++ b/packages/web/src/generic.test.ts @@ -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'; @@ -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', + ]); + }); + }); }); diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index 069307a..2e8931d 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -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; } @@ -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, diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx index 2ebd284..4d3f93e 100644 --- a/packages/web/src/nextjs/index.tsx +++ b/packages/web/src/nextjs/index.tsx @@ -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'; @@ -30,5 +30,5 @@ export function Analytics(props: Props): null { ) as never; } -export { track }; export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; +export { group, identify, reset, track }; diff --git a/packages/web/src/nuxt/runtime/index.ts b/packages/web/src/nuxt/runtime/index.ts index db48a1a..c89cb8d 100644 --- a/packages/web/src/nuxt/runtime/index.ts +++ b/packages/web/src/nuxt/runtime/index.ts @@ -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'; @@ -41,4 +41,4 @@ function injectAnalytics(props: Omit = {}): void { } } -export { injectAnalytics, track }; +export { group, identify, injectAnalytics, reset, track }; diff --git a/packages/web/src/react/index.tsx b/packages/web/src/react/index.tsx index 0825012..3dfaabb 100644 --- a/packages/web/src/react/index.tsx +++ b/packages/web/src/react/index.tsx @@ -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'; @@ -65,5 +65,5 @@ function Analytics( return null; } -export { track, Analytics }; export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; +export { Analytics, group, identify, reset, track }; diff --git a/packages/web/src/remix/index.tsx b/packages/web/src/remix/index.tsx index 989d679..29953fe 100644 --- a/packages/web/src/remix/index.tsx +++ b/packages/web/src/remix/index.tsx @@ -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'; @@ -15,3 +16,4 @@ export function Analytics(props: Omit): JSX.Element { ); } export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; +export { group, identify, reset, track }; diff --git a/packages/web/src/server/profiles.test.ts b/packages/web/src/server/attribution.test.ts similarity index 99% rename from packages/web/src/server/profiles.test.ts rename to packages/web/src/server/attribution.test.ts index 9a7a8d2..49d352d 100644 --- a/packages/web/src/server/profiles.test.ts +++ b/packages/web/src/server/attribution.test.ts @@ -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; diff --git a/packages/web/src/server/profiles.ts b/packages/web/src/server/attribution.ts similarity index 65% rename from packages/web/src/server/profiles.ts rename to packages/web/src/server/attribution.ts index 4f72d2b..8108174 100644 --- a/packages/web/src/server/profiles.ts +++ b/packages/web/src/server/attribution.ts @@ -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; +} + +/** + * 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 | 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; diff --git a/packages/web/src/server/experiments-types.ts b/packages/web/src/server/experiments-types.ts index d130bdc..1429918 100644 --- a/packages/web/src/server/experiments-types.ts +++ b/packages/web/src/server/experiments-types.ts @@ -1,4 +1,4 @@ -import type { AllowedPropertyValues } from '../types'; +import type { Attribution } from './attribution'; /** * Which unit an exposure applies to. @@ -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; -} +export interface ServerExposureInput extends ExposureInput, Attribution {} diff --git a/packages/web/src/server/experiments.test.ts b/packages/web/src/server/experiments.test.ts index f8e940b..9f5adcc 100644 --- a/packages/web/src/server/experiments.test.ts +++ b/packages/web/src/server/experiments.test.ts @@ -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; + + 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; + + 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( diff --git a/packages/web/src/server/experiments.ts b/packages/web/src/server/experiments.ts index 1c85691..890e13c 100644 --- a/packages/web/src/server/experiments.ts +++ b/packages/web/src/server/experiments.ts @@ -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, @@ -9,16 +9,23 @@ import { resolveEndpoint, } from './request'; -/** Exposures carry their own attribution, so flags do not apply to them. */ -export type ExposureOptions = Omit; +/** + * 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, 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( @@ -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 = { @@ -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, }), diff --git a/packages/web/src/server/index.test.ts b/packages/web/src/server/index.test.ts index 659eb40..e235469 100644 --- a/packages/web/src/server/index.test.ts +++ b/packages/web/src/server/index.test.ts @@ -73,6 +73,19 @@ describe('server track', () => { // Should not make fetch call due to error expect(fetchMock).not.toHaveBeenCalled(); }); + + it('throws for invalid attribution props', async () => { + await expect( + track('test', undefined, { + headers, + userId: 'user_123', + props: { plan: 'pro', address: { city: 'Paris' } } as never, + }), + ).rejects.toThrow( + 'The following properties are not valid: address. Only strings, numbers, booleans, and null are allowed.', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe('given production mode', () => { @@ -436,6 +449,65 @@ describe('server track', () => { ); }); + it('attaches userId, groupId and props, ids truncated to 256 characters', async () => { + const name = 'project_created'; + const longId = 'x'.repeat(300); + + await track( + name, + { source: 'api' }, + { + headers, + userId: 'user_123', + groupId: longId, + props: { plan: 'pro', seats: 12, churned: null }, + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/event`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + userId: 'user_123', + groupId: 'x'.repeat(256), + props: { plan: 'pro', seats: 12, churned: null }, + en: name, + ed: { source: 'api' }, + }), + }), + ); + }); + + it('strips invalid attribution props and omits them when empty', async () => { + await track('a', undefined, { + headers, + userId: 'user_123', + props: { nested: { a: 1 } } as never, + }); + await track('b', undefined, { + headers, + userId: 'user_123', + props: { plan: 'pro', nested: { a: 1 } } as never, + }); + + const bodies = fetchMock.mock.calls.map( + ([, init]) => + JSON.parse((init as RequestInit).body as string) as Record< + string, + unknown + >, + ); + expect(bodies[0]).not.toHaveProperty('props'); + expect(bodies[1]).toMatchObject({ props: { plan: 'pro' } }); + }); + it('throws error when no headers are available', async () => { await track('test'); diff --git a/packages/web/src/server/index.ts b/packages/web/src/server/index.ts index 3cd65ec..c4ed33e 100644 --- a/packages/web/src/server/index.ts +++ b/packages/web/src/server/index.ts @@ -4,6 +4,7 @@ import type { PlainFlags, } from '../types'; import { isProduction, parseProperties } from '../utils'; +import { type Attribution, attributionPayload } from './attribution'; import { dispatch, type Options, @@ -13,15 +14,31 @@ import { resolveEndpoint, } from './request'; +export interface TrackOptions extends Options, Attribution {} + +/** + * Tracks a custom event. Server-side only. + * + * The server keeps no profile: pass `userId`, `groupId` and `props` when you + * know them, for example from your session. + * + * @param eventName - The name of the event. + * @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. + * @param [options.userId] - The user the event is attributed to. + * @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. + */ export async function track( eventName: string, properties?: Record, - options?: Options, + options?: TrackOptions, ): Promise; export async function track( eventName: string, properties?: Record, - options?: Options & InternalOptions, + options?: TrackOptions & InternalOptions, ): Promise { if (rejectBrowserRuntime('track')) { return; @@ -31,6 +48,7 @@ export async function track( const props = parseProperties(properties, { strip: isProduction(), }); + const attribution = attributionPayload(options); if (!endpoint) { reportMissingEndpoint( @@ -44,6 +62,7 @@ export async function track( fnName: 'track', options, payload: (requestContext) => ({ + ...attribution, en: eventName, ed: props, f: safeGetFlags(options?.flags, requestContext), @@ -86,6 +105,8 @@ function safeGetFlags( } } +export type { Attribution, ProfileOptions } from './attribution'; +export { group, identify } from './attribution'; export type { ExposureAssignmentReason, ExposureInput, @@ -94,5 +115,3 @@ export type { ServerExposureInput, } from './experiments'; export { trackExposure } from './experiments'; -export type { ProfileOptions } from './profiles'; -export { group, identify } from './profiles'; diff --git a/packages/web/src/sveltekit-next/index.svelte.ts b/packages/web/src/sveltekit-next/index.svelte.ts index 7b10527..9548464 100644 --- a/packages/web/src/sveltekit-next/index.svelte.ts +++ b/packages/web/src/sveltekit-next/index.svelte.ts @@ -1,6 +1,6 @@ import type {} from '@sveltejs/kit'; import { page } from '$app/state'; -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'; @@ -37,4 +37,4 @@ function injectAnalytics(props: Omit = {}): void { } export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; -export { injectAnalytics, track }; +export { group, identify, injectAnalytics, reset, track }; diff --git a/packages/web/src/sveltekit/index.ts b/packages/web/src/sveltekit/index.ts index 3f35d08..f184b9a 100644 --- a/packages/web/src/sveltekit/index.ts +++ b/packages/web/src/sveltekit/index.ts @@ -2,7 +2,7 @@ import type {} from '@sveltejs/kit'; // @ts-expect-error $app/environment has been removed in kit 3 import { browser } from '$app/environment'; import { page } from '$app/stores'; -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'; @@ -30,4 +30,4 @@ function injectAnalytics(props: Omit = {}): void { } export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; -export { injectAnalytics, track }; +export { group, identify, injectAnalytics, reset, track }; diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 91ff6b9..493c788 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -65,7 +65,13 @@ declare global { interface Window { // Base interface va?: ( - event: 'beforeSend' | 'event' | 'pageview' | 'identify' | 'group', + event: + | 'beforeSend' + | 'event' + | 'pageview' + | 'identify' + | 'group' + | 'reset', properties?: unknown, ) => void; // Queue for actions, before the library is loaded diff --git a/packages/web/src/vue/index.ts b/packages/web/src/vue/index.ts index 24cfede..97819a2 100644 --- a/packages/web/src/vue/index.ts +++ b/packages/web/src/vue/index.ts @@ -1,5 +1,7 @@ +import { group, identify, reset, track } from '../generic'; import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types'; import { createComponent } from './create-component'; export const Analytics = createComponent(); export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; +export { group, identify, reset, track };