From 57dfdc7b0b78c4fc7e6c0008552f9daaaee9c247 Mon Sep 17 00:00:00 2001 From: feugy Date: Thu, 17 Sep 2026 14:34:36 +0200 Subject: [PATCH] feat: add identify/group and carry the CDP envelope Adds identify() and group() to the browser and server entries, posting to /_vercel/insights/identify and /group with flat traits. Every operation (track, pageview, identify, group, trackExposure) now forwards a runtime-only __cdp envelope untouched, next to the regular payload. The field is deliberately absent from the public type declarations, as agreed in the CDP transport ADR. --- packages/web/src/generic.test.ts | 156 ++++++++++- packages/web/src/generic.ts | 133 ++++++++-- packages/web/src/react/index.test.tsx | 5 +- packages/web/src/server/experiments.test.ts | 32 +++ packages/web/src/server/experiments.ts | 5 + packages/web/src/server/index.test.ts | 33 +++ packages/web/src/server/index.ts | 13 +- packages/web/src/server/profiles.test.ts | 276 ++++++++++++++++++++ packages/web/src/server/profiles.ts | 97 +++++++ packages/web/src/server/request.ts | 37 +-- packages/web/src/test-utils.ts | 11 + packages/web/src/types.ts | 12 +- 12 files changed, 761 insertions(+), 49 deletions(-) create mode 100644 packages/web/src/server/profiles.test.ts create mode 100644 packages/web/src/server/profiles.ts create mode 100644 packages/web/src/test-utils.ts diff --git a/packages/web/src/generic.test.ts b/packages/web/src/generic.test.ts index 072c60d..cc510d5 100644 --- a/packages/web/src/generic.test.ts +++ b/packages/web/src/generic.test.ts @@ -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', @@ -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: {} }, ]); }); }); @@ -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', () => { @@ -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' } }, + ]); + } }); }); }); diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index 6df4f27..069307a 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -6,6 +6,7 @@ import type { BeforeSendEvent, FlagsDataInput, InjectProps, + InternalOptions, } from './types'; import { computeRoute, @@ -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, - options?: { - flags?: FlagsDataInput; - }, + options?: { flags?: FlagsDataInput }, +): void; +function track( + name: string, + properties?: Record, + { __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 @@ -95,7 +117,7 @@ function track( initQueue(); if (!properties) { - window.va?.('event', { name, options }); + window.va?.('event', { name, options, __cdp }); return; } @@ -104,11 +126,7 @@ 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); @@ -116,18 +134,83 @@ function track( } } +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 | 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, +): void; +function identify( + userId: string, + traits?: Record, + 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, +): void; +function group( + groupId: string, + traits?: Record, + 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, diff --git a/packages/web/src/react/index.test.tsx b/packages/web/src/react/index.test.tsx index d564b6d..05a029a 100644 --- a/packages/web/src/react/index.test.tsx +++ b/packages/web/src/react/index.test.tsx @@ -114,6 +114,7 @@ describe('', () => { 'event', { name: 'my event', + options: {}, }, ]); }); @@ -124,7 +125,7 @@ describe('', () => { 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('strips data for nested objects', () => { @@ -136,7 +137,7 @@ describe('', () => { nested: { object: '' } as unknown as AllowedPropertyValues, }); - expect(window.vaq?.[0]).toEqual(['event', { name, data }]); + expect(window.vaq?.[0]).toEqual(['event', { name, data, options: {} }]); }); }); }); diff --git a/packages/web/src/server/experiments.test.ts b/packages/web/src/server/experiments.test.ts index c7057a3..f8e940b 100644 --- a/packages/web/src/server/experiments.test.ts +++ b/packages/web/src/server/experiments.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { name as packageName, version } from '../../package.json'; +import { withCdp } from '../test-utils'; import { trackExposure } from './experiments'; const sdkn = `${packageName}/server`; @@ -125,6 +126,37 @@ describe('trackExposure', () => { ); }); + it('forwards the __cdp envelope untouched', async () => { + const __cdp = { + schemaVersion: 1, + event: { type: 'track', context: { session: { id: 'abc' } } }, + }; + + await trackExposure(exposure, withCdp({ headers, __cdp })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: exposure.experimentId, + ed: { + variantId: exposure.variantId, + unitKey: exposure.unitKey, + unitValue: exposure.unitValue, + }, + __cdp, + }), + }), + ); + }); + it('includes ramp data when provided', async () => { await trackExposure( { diff --git a/packages/web/src/server/experiments.ts b/packages/web/src/server/experiments.ts index 18cc2d6..1c85691 100644 --- a/packages/web/src/server/experiments.ts +++ b/packages/web/src/server/experiments.ts @@ -1,3 +1,4 @@ +import type { InternalOptions } from '../types'; import { isProduction, parseProperties, truncateString } from '../utils'; import type { ServerExposureInput } from './experiments-types'; import { @@ -23,6 +24,10 @@ export type ExposureOptions = Omit; export async function trackExposure( input: ServerExposureInput, options?: ExposureOptions, +): Promise; +export async function trackExposure( + input: ServerExposureInput, + options?: ExposureOptions & InternalOptions, ): Promise { if (rejectBrowserRuntime('trackExposure')) { return; diff --git a/packages/web/src/server/index.test.ts b/packages/web/src/server/index.test.ts index e063e98..659eb40 100644 --- a/packages/web/src/server/index.test.ts +++ b/packages/web/src/server/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { name as packageName, version } from '../../package.json'; +import { withCdp } from '../test-utils'; import { track } from './index'; // @vitest-environment node @@ -403,6 +404,38 @@ describe('server track', () => { ); }); + it('forwards the __cdp envelope untouched', async () => { + const name = 'project_created'; + const data = { source: 'dashboard' }; + const __cdp = { + schemaVersion: 1, + event: { + type: 'track', + context: { attribution: { touches: [{ source: null }] } }, + }, + }; + + await track(name, data, withCdp({ headers, __cdp })); + + 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: '', + en: name, + ed: data, + __cdp, + }), + }), + ); + }); + 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 99eaab3..3cd65ec 100644 --- a/packages/web/src/server/index.ts +++ b/packages/web/src/server/index.ts @@ -1,4 +1,8 @@ -import type { AllowedPropertyValues, PlainFlags } from '../types'; +import type { + AllowedPropertyValues, + InternalOptions, + PlainFlags, +} from '../types'; import { isProduction, parseProperties } from '../utils'; import { dispatch, @@ -13,6 +17,11 @@ export async function track( eventName: string, properties?: Record, options?: Options, +): Promise; +export async function track( + eventName: string, + properties?: Record, + options?: Options & InternalOptions, ): Promise { if (rejectBrowserRuntime('track')) { return; @@ -85,3 +94,5 @@ 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/server/profiles.test.ts b/packages/web/src/server/profiles.test.ts new file mode 100644 index 0000000..9a7a8d2 --- /dev/null +++ b/packages/web/src/server/profiles.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment node +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'; + +const sdkn = `${packageName}/server`; +const sdkv = version; + +// Shared across both suites: `describe.each` bodies all run at collection +// time, so per-suite `global.fetch = vi.fn()` would leave only the last one. +const envSave = { ...process.env }; +const consoleLog = vi.spyOn(console, 'log'); +const consoleError = vi.spyOn(console, 'error'); +global.fetch = vi.fn(); +const fetchMock = vi.mocked(global.fetch); + +describe.each([ + { + fn: identify, + name: 'identify' as const, + label: 'Identify', + idKey: 'userId', + id: 'user_123', + }, + { + fn: group, + name: 'group' as const, + label: 'Group', + idKey: 'groupId', + id: 'team_456', + }, +])('$name', ({ fn, name, label, idKey, id }) => { + const headers = { + 'user-agent': 'test', + 'x-forwarded-for': '127.0.0.1', + }; + const appDomain = 'example.vercel.app'; + const endpoint = `https://${appDomain}/_vercel/insights/${name}`; + const traits = { plan: 'pro', seats: 12, active: true, churned: null }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + delete (global as { window?: unknown }).window; + process.env.NODE_ENV = 'production'; + consoleLog.mockImplementation(() => {}); + consoleError.mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = { ...envSave }; + vi.useRealTimers(); + }); + + describe('given development mode', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + it('prints the call to console', async () => { + await fn(id, { plan: 'pro' }); + + expect(consoleLog).toHaveBeenCalledWith( + `[Vercel Web Analytics] ${label} "${id}" with traits {"plan":"pro"}`, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not print when DISABLE_LOGS is true', async () => { + process.env.VERCEL_WEB_ANALYTICS_DISABLE_LOGS = 'true'; + + await fn(id); + + expect(consoleLog).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // The ingestion endpoint only accepts primitive trait values, like it does + // for custom event properties. Nested data belongs in `__cdp`. + it('throws for nested traits', async () => { + const invalid = { + plan: 'pro', + address: { city: 'Paris' }, + tags: ['a', 'b'], + } as Record; + + await expect(fn(id, invalid as never, { headers })).rejects.toThrow( + 'The following properties are not valid: address, tags. Only strings, numbers, booleans, and null are allowed.', + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws in a browser environment', async () => { + (global as { window?: { vam?: string } }).window = { + vam: 'development', + }; + + await expect(fn(id)).rejects.toThrow( + new RegExp( + `imported the \`${name}\` function from \`@vercel/analytics/server\` in a browser environment`, + ), + ); + }); + }); + + describe('given production mode', () => { + beforeEach(() => { + process.env.VERCEL_URL = appDomain; + fetchMock.mockResolvedValue({ + text: async () => 'ok', + } as Response); + }); + + it('prints log when VERCEL_URL is missing', async () => { + delete process.env.VERCEL_URL; + await fn(id); + + expect(consoleLog).toHaveBeenCalledWith( + "[Vercel Web Analytics] Can't find VERCEL_URL in environment variables.", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns early in a browser environment', async () => { + (global as { window?: object }).window = {}; + + await fn(id); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it(`posts flat traits, including null, to the ${name} endpoint`, async () => { + await fn(id, traits, { headers }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'x-va-server': '1', + }), + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + [idKey]: id, + ed: traits, + }), + }), + ); + }); + + it('omits traits when none are provided', async () => { + await fn(id, undefined, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + [idKey]: id, + }), + }), + ); + }); + + it('strips nested traits', async () => { + const invalid = { + plan: 'pro', + address: { city: 'Paris' }, + tags: ['a', 'b'], + } as Record; + + await fn(id, invalid as never, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + [idKey]: id, + ed: { plan: 'pro' }, + }), + }), + ); + }); + + it('truncates long identifiers', async () => { + await fn('x'.repeat(300), undefined, { headers }); + + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body[idKey]).toHaveLength(256); + }); + + it('forwards the __cdp envelope untouched', async () => { + const __cdp = { + schemaVersion: 1, + event: { + type: name, + context: { attribution: { touches: [{ source: null }] } }, + }, + }; + + await fn(id, { plan: 'pro' }, withCdp({ headers, __cdp })); + + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + en: name, + [idKey]: id, + ed: { plan: 'pro' }, + __cdp, + }), + }), + ); + }); + + it('treats VERCEL_WEB_ANALYTICS_ENDPOINT as a base url', async () => { + process.env.VERCEL_WEB_ANALYTICS_ENDPOINT = + 'https://analytics.example.com/ingest'; + + await fn(id, undefined, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://analytics.example.com/_vercel/insights/${name}`, + expect.anything(), + ); + }); + + it(`uses VERCEL_WEB_ANALYTICS_${name.toUpperCase()}_ENDPOINT verbatim`, async () => { + const override = `https://analytics.example.com/custom/${name}`; + process.env[`VERCEL_WEB_ANALYTICS_${name.toUpperCase()}_ENDPOINT`] = + override; + + await fn(id, undefined, { headers }); + + expect(fetchMock).toHaveBeenCalledWith(override, expect.anything()); + }); + + it('reports an error when no headers are available', async () => { + await fn(id); + + expect(consoleError).toHaveBeenCalledWith( + Error( + `No session context found. Pass \`request\` or \`headers\` to the \`${name}\` function.`, + ), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/web/src/server/profiles.ts b/packages/web/src/server/profiles.ts new file mode 100644 index 0000000..4f72d2b --- /dev/null +++ b/packages/web/src/server/profiles.ts @@ -0,0 +1,97 @@ +import type { AllowedPropertyValues, InternalOptions } from '../types'; +import { isProduction, parseProperties, truncateString } from '../utils'; +import { + dispatch, + type Options, + rejectBrowserRuntime, + reportMissingEndpoint, + resolveEndpoint, +} from './request'; + +/** Profile mutations carry no flags: they describe who, not what happened. */ +export type ProfileOptions = Omit; + +/** + * `identify` and `group` share everything but their name and identifier key. + * They post to their own endpoints and carry traits in `ed`, where custom + * events carry their properties. + */ +async function trackProfile( + name: 'identify' | 'group', + idKey: 'userId' | 'groupId', + id: string, + traits: Record | undefined, + options: (ProfileOptions & InternalOptions) | undefined, +): Promise { + if (rejectBrowserRuntime(name)) { + return; + } + + const endpoint = resolveEndpoint(name); + const parsedTraits = parseProperties(traits, { + strip: isProduction(), + }); + + if (!endpoint) { + const label = name === 'identify' ? 'Identify' : 'Group'; + reportMissingEndpoint( + `${label} "${id}" ${parsedTraits ? `with traits ${JSON.stringify(parsedTraits)}` : ''}`, + ); + return; + } + + await dispatch({ + endpoint, + fnName: name, + options, + payload: () => ({ + en: name, + [idKey]: truncateString(id), + ...(parsedTraits && + Object.keys(parsedTraits).length > 0 && { ed: parsedTraits }), + }), + }); +} + +/** + * Associates the current visitor with a user. Server-side only. + * + * @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. + */ +export async function identify( + userId: string, + traits?: Record, + options?: ProfileOptions, +): Promise; +export async function identify( + userId: string, + traits?: Record, + options?: ProfileOptions & InternalOptions, +): Promise { + await trackProfile('identify', 'userId', userId, traits, options); +} + +/** + * Associates the current visitor with a group, like a team or an organization. + * Server-side only. + * + * @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. + */ +export async function group( + groupId: string, + traits?: Record, + options?: ProfileOptions, +): Promise; +export async function group( + groupId: string, + traits?: Record, + options?: ProfileOptions & InternalOptions, +): Promise { + await trackProfile('group', 'groupId', groupId, traits, options); +} diff --git a/packages/web/src/server/request.ts b/packages/web/src/server/request.ts index 72e2aa6..1de41ab 100644 --- a/packages/web/src/server/request.ts +++ b/packages/web/src/server/request.ts @@ -1,5 +1,5 @@ import { name as packageName, version } from '../../package.json'; -import type { FlagsDataInput, PlainFlags } from '../types'; +import type { FlagsDataInput, InternalOptions, PlainFlags } from '../types'; import { isProduction } from '../utils'; /** @@ -38,7 +38,7 @@ const symbol = Symbol.for('@vercel/request-context'); const logPrefix = '[Vercel Web Analytics]'; /** Name of a public sender, quoted back to the user in messages. */ -export type SenderName = 'track' | 'trackExposure'; +export type SenderName = 'track' | 'trackExposure' | 'identify' | 'group'; function isHeaders(headers?: AllowedHeaders): headers is Headers { if (!headers) return false; @@ -125,19 +125,21 @@ export function resolveHeaders( return { requestHeaders, hasHeaders: Boolean(headers) }; } +/** Ingestion routes a server sender can post to. */ +export type EndpointKind = 'event' | 'exposure' | 'identify' | 'group'; + /** * `VERCEL_WEB_ANALYTICS_ENDPOINT` is used verbatim for events. - * For exposures it is treated as a base URL, unless - * `VERCEL_WEB_ANALYTICS_EXPOSURE_ENDPOINT` overrides it. + * For the other kinds it is treated as a base URL, unless their dedicated + * `VERCEL_WEB_ANALYTICS__ENDPOINT` variable overrides it. */ -export function resolveEndpoint( - kind: 'event' | 'exposure', -): string | undefined { - if ( - kind === 'exposure' && - process.env.VERCEL_WEB_ANALYTICS_EXPOSURE_ENDPOINT - ) { - return process.env.VERCEL_WEB_ANALYTICS_EXPOSURE_ENDPOINT; +export function resolveEndpoint(kind: EndpointKind): string | undefined { + if (kind !== 'event') { + const override = + process.env[`VERCEL_WEB_ANALYTICS_${kind.toUpperCase()}_ENDPOINT`]; + if (override) { + return override; + } } const base = @@ -161,8 +163,11 @@ export interface DispatchOptions { endpoint: string; /** Public function name, quoted back to the user in messages. */ fnName: SenderName; - /** Caller options, the source of explicitly passed headers. */ - options: Omit | undefined; + /** + * Caller options, the source of explicitly passed headers, and of the + * runtime-only `__cdp` envelope. + */ + options: (Omit & InternalOptions) | undefined; /** * The fields that make this event what it is, merged over the shared * envelope. Receives the request context so a sender can read flags from it. @@ -173,7 +178,8 @@ export interface DispatchOptions { /** * Wraps a payload in the envelope every event carries (origin, timestamp, SDK * name and version) and posts it, forwarding the session identity of the - * incoming request. Never throws: a sender that fails must not take the + * incoming request. When the caller passed a CDP envelope, it is sent as-is + * next to the payload. Never throws: a sender that fails must not take the * surrounding request down with it. */ export async function dispatch({ @@ -205,6 +211,7 @@ export async function dispatch({ sdkv: version, r: '', ...payload(requestContext), + __cdp: options?.__cdp, }; const BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; diff --git a/packages/web/src/test-utils.ts b/packages/web/src/test-utils.ts new file mode 100644 index 0000000..663b320 --- /dev/null +++ b/packages/web/src/test-utils.ts @@ -0,0 +1,11 @@ +import type { InternalOptions } from './types'; + +/** + * `__cdp` is not part of the public signatures. Like Vercel's CDP, tests + * widen the options with the runtime-only contract through one assertion. + */ +export function withCdp( + options: Partial & InternalOptions, +): T { + return options as T; +} diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 1569798..91ff6b9 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -9,6 +9,16 @@ export type AllowedPropertyValues = export type PlainFlags = Record; export type FlagsDataInput = (string | PlainFlags)[] | PlainFlags; +/** + * Vercel's CDP attaches its envelope to the options of every operation at + * runtime. The field is deliberately absent from the public signatures: the + * SDK never reads it and forwards it unchanged, next to the regular payload. + * Validation, limits and project gating happen at the ingestion endpoint. + */ +export interface InternalOptions { + __cdp?: unknown; +} + export type TrackEventPayload = { name: string; data?: Record; @@ -55,7 +65,7 @@ declare global { interface Window { // Base interface va?: ( - event: 'beforeSend' | 'event' | 'pageview', + event: 'beforeSend' | 'event' | 'pageview' | 'identify' | 'group', properties?: unknown, ) => void; // Queue for actions, before the library is loaded