diff --git a/packages/web/package.json b/packages/web/package.json index 9467c70..d0c6080 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/analytics", - "version": "2.2.0-canary", + "version": "2.3.0-canary", "description": "Gain real-time traffic insights with Vercel Web Analytics", "keywords": [ "analytics", diff --git a/packages/web/src/server/browser.test.ts b/packages/web/src/server/browser.test.ts index 527d409..c495037 100644 --- a/packages/web/src/server/browser.test.ts +++ b/packages/web/src/server/browser.test.ts @@ -1,5 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { track } from './index'; +import { track, trackExposure } from './index'; + +const exposure = { + experimentId: 'checkout-redesign', + variantId: 'treatment', + unitKey: 'user' as const, + unitValue: 'user_123', +}; describe('server track in browser environment', () => { global.fetch = vi.fn(); @@ -13,7 +20,7 @@ describe('server track in browser environment', () => { (global as { window?: { vam?: string } }).window = { vam: 'development' }; await expect(track('test-event')).rejects.toThrow( - /imported the `track` function from `@vercel\/web-analytics\/server` in a browser environment/, + /imported the `track` function from `@vercel\/analytics\/server` in a browser environment/, ); }); @@ -24,4 +31,20 @@ describe('server track in browser environment', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + + it('throws for trackExposure in development mode', async () => { + (global as { window?: { vam?: string } }).window = { vam: 'development' }; + + await expect(trackExposure(exposure)).rejects.toThrow( + /imported the `trackExposure` function from `@vercel\/analytics\/server` in a browser environment/, + ); + }); + + it('returns early for trackExposure in production mode', async () => { + (global as { window?: object }).window = {}; + + await trackExposure(exposure); + + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/server/experiments-types.ts b/packages/web/src/server/experiments-types.ts new file mode 100644 index 0000000..d130bdc --- /dev/null +++ b/packages/web/src/server/experiments-types.ts @@ -0,0 +1,46 @@ +import type { AllowedPropertyValues } from '../types'; + +/** + * Which unit an exposure applies to. + * + * `user`, `device` and `group` point at the attribution that the SDK attaches + * to every event itself. Do not put these in the data of your `track()` calls. + * + * `event_data.${property}` points at a property in the data of your `track()` + * calls. So `event_data.user` is a `user` property that you send yourself, + * which is a different unit than `user`. + */ +export type ExposureUnitKey = + | 'user' + | 'device' + | 'group' + | `event_data.${string}`; + +export type ExposureAssignmentReason = + | 'experiment' + | 'not-enrolled' + | 'targeted' + | 'split' + | 'variant' + | 'rollout' + | 'override'; + +export interface ExposureInput { + experimentId: string; + variantId: string; + unitKey: ExposureUnitKey; + unitValue: string; + assignmentReason?: ExposureAssignmentReason; + rampId?: string; + rampPercentage?: number; +} + +/** + * 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; +} diff --git a/packages/web/src/server/experiments.test.ts b/packages/web/src/server/experiments.test.ts new file mode 100644 index 0000000..c7057a3 --- /dev/null +++ b/packages/web/src/server/experiments.test.ts @@ -0,0 +1,362 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { name as packageName, version } from '../../package.json'; +import { trackExposure } from './experiments'; + +const sdkn = `${packageName}/server`; +const sdkv = version; + +describe('trackExposure', () => { + 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); + + const headers = { + 'user-agent': 'test', + 'x-forwarded-for': '127.0.0.1', + }; + const appDomain = 'example.vercel.app'; + const exposure = { + experimentId: 'checkout-redesign', + variantId: 'treatment', + unitKey: 'user' as const, + unitValue: 'user_123', + }; + + 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 exposures to console', async () => { + await trackExposure(exposure); + + expect(consoleLog).toHaveBeenCalledWith( + '[Vercel Web Analytics] Exposure "checkout-redesign:treatment" with data {"variantId":"treatment","unitKey":"user","unitValue":"user_123"}', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not print when DISABLE_LOGS is true', async () => { + process.env.VERCEL_WEB_ANALYTICS_DISABLE_LOGS = 'true'; + + await trackExposure(exposure); + + expect(consoleLog).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws for invalid props', async () => { + const props = { + valid: 'test', + anObject: { nested: 'object' }, + } as Record; + + await expect( + trackExposure({ ...exposure, props: props as never }, { headers }), + ).rejects.toThrow( + 'The following properties are not valid: anObject. Only strings, numbers, booleans, and null are allowed.', + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('given production mode', () => { + beforeEach(() => { + process.env.VERCEL_URL = appDomain; + fetchMock.mockResolvedValue({ + text: async () => 'ok', + } as Response); + }); + + it('prints log in production when VERCEL_URL is missing', async () => { + delete process.env.VERCEL_URL; + await trackExposure(exposure); + + expect(consoleLog).toHaveBeenCalledWith( + "[Vercel Web Analytics] Can't find VERCEL_URL in environment variables.", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts an exposure to the exposure endpoint', async () => { + await trackExposure(exposure, { headers }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + 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: exposure.experimentId, + ed: { + variantId: exposure.variantId, + unitKey: exposure.unitKey, + unitValue: exposure.unitValue, + }, + }), + }), + ); + }); + + it('includes ramp data when provided', async () => { + await trackExposure( + { + ...exposure, + assignmentReason: 'experiment', + rampId: 'ramp_1', + rampPercentage: 25, + }, + { headers }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + expect.objectContaining({ + 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, + assignmentReason: 'experiment', + rampId: 'ramp_1', + rampPercentage: 25, + }, + }), + }), + ); + }); + + it('includes attribution passed on the call side', async () => { + await trackExposure( + { + ...exposure, + userId: 'user_123', + groupId: 'acme', + props: { plan: 'pro', seats: 12 }, + }, + { headers }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + expect.objectContaining({ + body: JSON.stringify({ + o: `https://${appDomain}`, + ts: vi.getMockedSystemTime()?.getTime(), + sdkn, + sdkv, + r: '', + userId: 'user_123', + groupId: 'acme', + props: { plan: 'pro', seats: 12 }, + en: exposure.experimentId, + ed: { + variantId: exposure.variantId, + unitKey: exposure.unitKey, + unitValue: exposure.unitValue, + }, + }), + }), + ); + }); + + it('truncates long userId and groupId', async () => { + const long = 'a'.repeat(300); + await trackExposure( + { ...exposure, userId: long, groupId: long }, + { headers }, + ); + + const body = JSON.parse( + (fetchMock.mock.calls[0]?.[1] as RequestInit | undefined) + ?.body as string, + ) as { userId: string; groupId: string }; + + expect(body.userId).toHaveLength(256); + expect(body.groupId).toHaveLength(256); + }); + + it('strips invalid props in production', async () => { + await trackExposure( + { + ...exposure, + props: { valid: 'test', invalid: { nested: 'object' } } as never, + }, + { headers }, + ); + + const body = JSON.parse( + (fetchMock.mock.calls[0]?.[1] as RequestInit | undefined) + ?.body as string, + ) as { props: Record }; + + expect(body.props).toEqual({ valid: 'test' }); + }); + + it('reuses provided referer, user-agent, cookie and IP headers', async () => { + const userAgent = 'custom-agent/2.0'; + const cookie = 'session=def456'; + const ip = '190.80.130.60'; + const referer = 'https://acme.org/blog'; + + await trackExposure(exposure, { + headers: new Headers({ + 'user-agent': userAgent, + 'x-forwarded-for': ip, + cookie, + referer, + }), + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + expect.objectContaining({ + headers: { + 'content-type': 'application/json', + 'user-agent': userAgent, + 'x-vercel-ip': ip, + cookie, + 'x-va-server': '1', + }, + body: expect.stringContaining(`"o":"${referer}"`) as string, + }), + ); + }); + + it('accepts headers via request', async () => { + await trackExposure(exposure, { + request: { headers: new Headers(headers) }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('treats VERCEL_WEB_ANALYTICS_ENDPOINT as a base url', async () => { + process.env.VERCEL_WEB_ANALYTICS_ENDPOINT = + 'https://analytics.example.com/38189204861386'; + + await trackExposure(exposure, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://analytics.example.com/_vercel/insights/exposure', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('uses VERCEL_WEB_ANALYTICS_EXPOSURE_ENDPOINT verbatim', async () => { + const endpoint = 'https://analytics.example.com/exposures/123'; + process.env.VERCEL_WEB_ANALYTICS_EXPOSURE_ENDPOINT = endpoint; + + await trackExposure(exposure, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('includes the provided bypass secret', async () => { + process.env.VERCEL_AUTOMATION_BYPASS_SECRET = 'secretXYZ'; + + await trackExposure(exposure, { headers }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://${appDomain}/_vercel/insights/exposure`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-vercel-protection-bypass': 'secretXYZ', + }), + }), + ); + }); + + it('reports an error when no headers are available', async () => { + await trackExposure(exposure); + + expect(consoleError).toHaveBeenCalledWith( + Error( + 'No session context found. Pass `request` or `headers` to the `trackExposure` function.', + ), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('gracefully handles fetch errors', async () => { + const error = new Error('Network error'); + fetchMock.mockRejectedValueOnce(error); + + await trackExposure(exposure, { headers }); + + expect(consoleError).toHaveBeenCalledWith(error); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + describe('given request context', () => { + const symbol = Symbol.for('@vercel/request-context'); + let requestContext: unknown = null; + + beforeEach(() => { + (globalThis as Record)[symbol] = { + get: () => requestContext, + }; + }); + + afterEach(() => { + delete (globalThis as Record)[symbol]; + }); + + it('uses headers from request context', async () => { + requestContext = { headers: new Headers(headers) }; + + await trackExposure(exposure); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('uses waitUntil', async () => { + const waitUntil = vi.fn(); + requestContext = { headers, waitUntil }; + + await trackExposure(exposure); + + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise)); + }); + }); + }); +}); diff --git a/packages/web/src/server/experiments.ts b/packages/web/src/server/experiments.ts new file mode 100644 index 0000000..18cc2d6 --- /dev/null +++ b/packages/web/src/server/experiments.ts @@ -0,0 +1,80 @@ +import { isProduction, parseProperties, truncateString } from '../utils'; +import type { ServerExposureInput } from './experiments-types'; +import { + dispatch, + type Options, + rejectBrowserRuntime, + reportMissingEndpoint, + resolveEndpoint, +} from './request'; + +/** Exposures carry their own attribution, so flags do not apply to them. */ +export type ExposureOptions = Omit; + +/** + * 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 + * outside of a Vercel Function, where no request context is available. + */ +export async function trackExposure( + input: ServerExposureInput, + options?: ExposureOptions, +): Promise { + if (rejectBrowserRuntime('trackExposure')) { + return; + } + + const endpoint = resolveEndpoint('exposure'); + const props = parseProperties(input.props, { + strip: isProduction(), + }); + + const data = { + // experimentId is sent as `en`, so it is left out of `ed` + variantId: input.variantId, + unitKey: input.unitKey, + unitValue: input.unitValue, + ...(input.assignmentReason !== undefined && { + assignmentReason: input.assignmentReason, + }), + ...(input.rampId !== undefined && { rampId: input.rampId }), + ...(input.rampPercentage !== undefined && { + rampPercentage: input.rampPercentage, + }), + }; + + if (!endpoint) { + reportMissingEndpoint( + `Exposure "${input.experimentId}:${input.variantId}" with data ${JSON.stringify(data)}`, + ); + return; + } + + await dispatch({ + endpoint, + 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 }), + en: input.experimentId, + ed: data, + }), + }); +} + +export type { + ExposureAssignmentReason, + ExposureInput, + ExposureUnitKey, + ServerExposureInput, +} from './experiments-types'; diff --git a/packages/web/src/server/index.ts b/packages/web/src/server/index.ts index f540871..99eaab3 100644 --- a/packages/web/src/server/index.ts +++ b/packages/web/src/server/index.ts @@ -1,172 +1,50 @@ -import { name as packageName, version } from '../../package.json'; -import type { - AllowedPropertyValues, - FlagsDataInput, - PlainFlags, -} from '../types'; +import type { AllowedPropertyValues, PlainFlags } from '../types'; import { isProduction, parseProperties } from '../utils'; - -type HeadersObject = Record; -type AllowedHeaders = Headers | HeadersObject; - -function isHeaders(headers?: AllowedHeaders): headers is Headers { - if (!headers) return false; - return typeof (headers as HeadersObject).entries === 'function'; -} - -interface Options { - flags?: FlagsDataInput; - headers?: AllowedHeaders; - request?: { headers: AllowedHeaders }; -} - -interface RequestContext { - get: () => { - headers: Record; - url: string; - waitUntil?: (promise: Promise) => void; - flags?: { - getValues: () => PlainFlags; - reportValue: (key: string, value: unknown) => void; - }; - }; -} - -const symbol = Symbol.for('@vercel/request-context'); -const logPrefix = '[Vercel Web Analytics]'; +import { + dispatch, + type Options, + type ResolvedRequestContext, + rejectBrowserRuntime, + reportMissingEndpoint, + resolveEndpoint, +} from './request'; export async function track( eventName: string, properties?: Record, options?: Options, ): Promise { - const ENDPOINT = - process.env.VERCEL_WEB_ANALYTICS_ENDPOINT || process.env.VERCEL_URL; - const DISABLE_LOGS = Boolean(process.env.VERCEL_WEB_ANALYTICS_DISABLE_LOGS); - const BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; - - if (typeof window !== 'undefined') { - if (!isProduction()) { - throw new Error( - `${logPrefix} It seems like you imported the \`track\` function from \`@vercel/web-analytics/server\` in a browser environment. This function is only meant to be used in a server environment.`, - ); - } - + if (rejectBrowserRuntime('track')) { return; } + const endpoint = resolveEndpoint('event'); const props = parseProperties(properties, { strip: isProduction(), }); - if (!ENDPOINT) { - if (isProduction()) { - console.log( - `${logPrefix} Can't find VERCEL_URL in environment variables.`, - ); - } else if (!DISABLE_LOGS) { - console.log( - `${logPrefix} Track "${eventName}" ${ - props ? `with data ${JSON.stringify(props)}` : '' - }`, - ); - } + if (!endpoint) { + reportMissingEndpoint( + `Track "${eventName}" ${props ? `with data ${JSON.stringify(props)}` : ''}`, + ); return; } - try { - const requestContext = ( - (globalThis as never)[symbol] as RequestContext | undefined - )?.get(); - - let headers: AllowedHeaders | undefined; - if (options && 'headers' in options) { - headers = options.headers; - } else if (options?.request) { - headers = options.request.headers; - } else if (requestContext?.headers) { - // not explicitly passed in context, so take it from async storage - headers = requestContext.headers; - } - - let tmp: HeadersObject = {}; - if (headers && isHeaders(headers)) { - headers.forEach((value, key) => { - tmp[key] = value; - }); - } else if (headers) { - tmp = headers; - } - - const url = ENDPOINT.startsWith('http') - ? ENDPOINT - : new URL('/_vercel/insights/event', `https://${ENDPOINT}`).toString(); - - const body = { - o: requestContext?.url || (tmp.referer as string) || new URL(url).origin, - ts: Date.now(), - sdkn: `${packageName}/server`, - sdkv: version, - r: '', + await dispatch({ + endpoint, + fnName: 'track', + options, + payload: (requestContext) => ({ en: eventName, ed: props, f: safeGetFlags(options?.flags, requestContext), - }; - - const hasHeaders = Boolean(headers); - - if (!hasHeaders) { - throw new Error( - 'No session context found. Pass `request` or `headers` to the `track` function.', - ); - } - - const promise = fetch(url, { - headers: { - 'content-type': 'application/json', - ...(hasHeaders - ? { - 'user-agent': tmp['user-agent'] as string, - 'x-vercel-ip': tmp['x-forwarded-for'] as string, - 'x-va-server': '1', - cookie: tmp.cookie as string, - } - : { - 'x-va-server': '2', - }), - ...(BYPASS_SECRET - ? { 'x-vercel-protection-bypass': BYPASS_SECRET } - : {}), - }, - body: JSON.stringify(body), - method: 'POST', - }) - // We want to always consume the body; some cloud providers track fetch concurrency - // and may not release the connection until the body is consumed. - .then((response) => response.text()) - .catch((err: unknown) => { - if (err instanceof Error && 'response' in err) { - console.error(err.response); - } else { - console.error(err); - } - }); - - if (requestContext?.waitUntil) { - requestContext.waitUntil(promise); - } else { - await promise; - } - - return void 0; - } catch (err) { - console.error(err); - } + }), + }); } function safeGetFlags( flags: Options['flags'], - requestContext?: ReturnType, + requestContext?: ResolvedRequestContext, ): | { p: PlainFlags; @@ -198,3 +76,12 @@ function safeGetFlags( /* empty */ } } + +export type { + ExposureAssignmentReason, + ExposureInput, + ExposureOptions, + ExposureUnitKey, + ServerExposureInput, +} from './experiments'; +export { trackExposure } from './experiments'; diff --git a/packages/web/src/server/request.ts b/packages/web/src/server/request.ts new file mode 100644 index 0000000..72e2aa6 --- /dev/null +++ b/packages/web/src/server/request.ts @@ -0,0 +1,245 @@ +import { name as packageName, version } from '../../package.json'; +import type { FlagsDataInput, PlainFlags } from '../types'; +import { isProduction } from '../utils'; + +/** + * Everything the server-side senders (`track`, `trackExposure`, ...) share: + * runtime guards, endpoint resolution, header forwarding, and firing the + * request without leaking the connection. A sender should only have to + * describe *what* it sends. + */ + +export type HeadersObject = Record; +export type AllowedHeaders = Headers | HeadersObject; + +export interface Options { + flags?: FlagsDataInput; + headers?: AllowedHeaders; + request?: { headers: AllowedHeaders }; +} + +export interface RequestContext { + get: () => { + headers: Record; + url: string; + waitUntil?: (promise: Promise) => void; + flags?: { + getValues: () => PlainFlags; + reportValue: (key: string, value: unknown) => void; + }; + }; +} + +export type ResolvedRequestContext = + | ReturnType + | undefined; + +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'; + +function isHeaders(headers?: AllowedHeaders): headers is Headers { + if (!headers) return false; + return typeof (headers as HeadersObject).entries === 'function'; +} + +/** + * The server senders must never run in the browser. Throws in development so + * the mistake is loud, and reports back in production so the caller can bail + * out silently. + * + * @returns `true` when the caller must return early. + */ +export function rejectBrowserRuntime(fnName: SenderName): boolean { + if (typeof window === 'undefined') { + return false; + } + + if (!isProduction()) { + throw new Error( + `${logPrefix} It seems like you imported the \`${fnName}\` function from \`@vercel/analytics/server\` in a browser environment. This function is only meant to be used in a server environment.`, + ); + } + + return true; +} + +/** + * Explains why nothing was sent when no endpoint could be resolved: a real + * misconfiguration in production, and the payload itself in development, where + * printing it is the whole point. + * + * @param devMessage - What to print in development, already formatted. + */ +export function reportMissingEndpoint(devMessage: string): void { + if (isProduction()) { + console.log(`${logPrefix} Can't find VERCEL_URL in environment variables.`); + return; + } + + if (!process.env.VERCEL_WEB_ANALYTICS_DISABLE_LOGS) { + console.log(`${logPrefix} ${devMessage}`); + } +} + +export function getRequestContext(): ResolvedRequestContext { + return ((globalThis as never)[symbol] as RequestContext | undefined)?.get(); +} + +export interface ResolvedHeaders { + /** Incoming request headers, flattened to a plain object. */ + requestHeaders: HeadersObject; + /** + * Whether headers were found at all. An empty `Headers` instance still + * counts as found, so this is not `Object.keys(requestHeaders).length > 0`. + */ + hasHeaders: boolean; +} + +export function resolveHeaders( + options: Omit | undefined, + requestContext: ResolvedRequestContext, +): ResolvedHeaders { + let headers: AllowedHeaders | undefined; + + if (options && 'headers' in options) { + headers = options.headers; + } else if (options?.request) { + headers = options.request.headers; + } else if (requestContext?.headers) { + // not explicitly passed in context, so take it from async storage + headers = requestContext.headers; + } + + let requestHeaders: HeadersObject = {}; + if (headers && isHeaders(headers)) { + headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + } else if (headers) { + requestHeaders = headers; + } + + return { requestHeaders, hasHeaders: Boolean(headers) }; +} + +/** + * `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. + */ +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; + } + + const base = + process.env.VERCEL_WEB_ANALYTICS_ENDPOINT || process.env.VERCEL_URL; + + if (!base) { + return undefined; + } + + if (base.startsWith('http')) { + return kind === 'event' + ? base + : new URL(`/_vercel/insights/${kind}`, base).toString(); + } + + return new URL(`/_vercel/insights/${kind}`, `https://${base}`).toString(); +} + +export interface DispatchOptions { + /** Absolute URL of the ingestion endpoint. */ + 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; + /** + * 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. + */ + payload: (requestContext: ResolvedRequestContext) => Record; +} + +/** + * 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 + * surrounding request down with it. + */ +export async function dispatch({ + endpoint, + fnName, + options, + payload, +}: DispatchOptions): Promise { + try { + const requestContext = getRequestContext(); + const { requestHeaders, hasHeaders } = resolveHeaders( + options, + requestContext, + ); + + if (!hasHeaders) { + throw new Error( + `No session context found. Pass \`request\` or \`headers\` to the \`${fnName}\` function.`, + ); + } + + const body = { + o: + requestContext?.url || + (requestHeaders.referer as string) || + new URL(endpoint).origin, + ts: Date.now(), + sdkn: `${packageName}/server`, + sdkv: version, + r: '', + ...payload(requestContext), + }; + + const BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + + const promise = fetch(endpoint, { + headers: { + 'content-type': 'application/json', + 'user-agent': requestHeaders['user-agent'] as string, + 'x-vercel-ip': requestHeaders['x-forwarded-for'] as string, + 'x-va-server': '1', + cookie: requestHeaders.cookie as string, + ...(BYPASS_SECRET + ? { 'x-vercel-protection-bypass': BYPASS_SECRET } + : {}), + }, + body: JSON.stringify(body), + method: 'POST', + }) + // We want to always consume the body; some cloud providers track fetch concurrency + // and may not release the connection until the body is consumed. + .then((response) => response.text()) + .catch((err: unknown) => { + if (err instanceof Error && 'response' in err) { + console.error(err.response); + } else { + console.error(err); + } + }); + + if (requestContext?.waitUntil) { + requestContext.waitUntil(promise); + } else { + await promise; + } + } catch (err) { + console.error(err); + } +} diff --git a/packages/web/src/utils.test.ts b/packages/web/src/utils.test.ts index 9ef8ea2..2abb229 100644 --- a/packages/web/src/utils.test.ts +++ b/packages/web/src/utils.test.ts @@ -3,8 +3,10 @@ import { computeRoute, getMode, loadProps, + MAX_ATTRIBUTION_STRING_LENGTH, parseProperties, setMode, + truncateString, } from './utils'; describe('utils', () => { @@ -68,6 +70,41 @@ describe('utils', () => { }); }); + describe('truncateString()', () => { + it('leaves a string shorter than the limit untouched', () => { + expect(truncateString('user_123')).toEqual('user_123'); + }); + + it('leaves a string of exactly the limit untouched', () => { + const value = 'a'.repeat(MAX_ATTRIBUTION_STRING_LENGTH); + + expect(truncateString(value)).toEqual(value); + }); + + it('cuts a longer string down to the limit', () => { + const value = `${'a'.repeat(MAX_ATTRIBUTION_STRING_LENGTH)}b`; + + expect(truncateString(value)).toHaveLength(MAX_ATTRIBUTION_STRING_LENGTH); + expect(truncateString(value)).not.toContain('b'); + }); + + it('handles an empty string', () => { + expect(truncateString('')).toEqual(''); + }); + + it('truncates by code unit, so it can split a surrogate pair', () => { + // '😀' is 2 UTF-16 code units, so a limit landing mid-pair yields a + // lone surrogate. The ingestion endpoint slices the same way, so the + // SDK must not paper over it. + const value = `${'a'.repeat(MAX_ATTRIBUTION_STRING_LENGTH - 1)}😀`; + + const result = truncateString(value); + + expect(result).toHaveLength(MAX_ATTRIBUTION_STRING_LENGTH); + expect(result.endsWith('😀')).toBe(false); + }); + }); + describe('setMode', () => { describe('in production mode', () => { beforeAll(() => { diff --git a/packages/web/src/utils.ts b/packages/web/src/utils.ts index bcd89f6..032d18a 100644 --- a/packages/web/src/utils.ts +++ b/packages/web/src/utils.ts @@ -81,6 +81,16 @@ export function parseProperties( return props as Record; } +/** + * Attribution strings are truncated to the same length the ingestion endpoint + * applies, so what the SDK sends is what gets stored. + */ +export const MAX_ATTRIBUTION_STRING_LENGTH = 256; + +export function truncateString(value: string): string { + return value.slice(0, MAX_ATTRIBUTION_STRING_LENGTH); +} + export function computeRoute( pathname: string | null, pathParams: Record | null,