From e561d1846d7788dc449fa5cb6e93c41696c73a77 Mon Sep 17 00:00:00 2001 From: Praveen Kumar Date: Sun, 20 Sep 2026 14:07:36 +0530 Subject: [PATCH 1/4] Add track() location option for visitor geo --- README.md | 22 +++++- src/index.ts | 8 ++- src/pug.test.ts | 12 ++++ src/track.test.ts | 142 ++++++++++++++++++++++++++++++++++++++- src/track.ts | 129 +++++++++++++++++++++++++++++++++-- src/well-known-events.ts | 27 +++++++- 6 files changed, 329 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4d8b206..1b42cea 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,30 @@ new Pug({ - `pug.flush()` — send the currently-buffered events now. - `pug.close()` — drain and shut down; call on graceful exit so nothing buffered is lost. -`track` options: `timestamp` (epoch ms override), `sessionId` (per-call session override). +`track` options: `timestamp` (epoch ms override), `sessionId` (per-call session override), +`location` (the visitor's location — see below). `identify` options: `anonymousId` (must start with `anon-`; triggers anon→identified merge), `deviceId`. +Pug only derives geo from CDN headers on browser (public-key) requests. This SDK uses a +private key, so the server adds no geo of its own — without `location`, an event has no geo +at all: + +```ts +pug.track('user-123', 'checkout_completed', { plan: 'pro' }, { + location: { country: 'DE', city: 'Berlin' }, +}); +``` + +Fields: `continent`, `country`, `region`, `city`, `postalCode`, `metroCode`, `timezone`, +`latitude`, `longitude` — strings, except the two coordinates. `country` must be an ISO +3166-1 alpha-2 code (case and surrounding spaces are normalized); `latitude` and `longitude` +are only sent as a pair. + +A field that is missing, null or empty is omitted silently. One that looks like a mistake — +unknown key, wrong type, unknown country, coordinate out of range — is dropped with a +warning and the rest is still sent. If nothing is usable the event goes out with no geo. + ### Reads (throw `PugError`) These require a private key and run in your control flow: diff --git a/src/index.ts b/src/index.ts index b3ec1aa..9ad5f1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,10 @@ export type { BatchConfig, OnError } from './batch.js' export { PugError } from './errors.js' export type { IdentifyOptions, Options } from './pug.js' export { Pug } from './pug.js' -export type { TrackFn, TrackOptions, WellKnownEventName, WellKnownEventPropsMap } from './track.js' +export type { + EventLocation, + TrackFn, + TrackOptions, + WellKnownEventName, + WellKnownEventPropsMap, +} from './track.js' diff --git a/src/pug.test.ts b/src/pug.test.ts index bd6bc81..950f57f 100644 --- a/src/pug.test.ts +++ b/src/pug.test.ts @@ -82,4 +82,16 @@ describe('Pug', () => { expect(caught).toBeInstanceOf(PugError) expect((caught as PugError).code).toBe(Code.PermissionDenied) }) + + it('track() carries options through to the event', () => { + const pug = newClient() + const sent: { autoProperties: Record; occurTime: unknown }[] = [] + ;(pug as unknown as { transport: unknown }).transport = { send: (e: never) => sent.push(e) } + + pug.track('user_1', 'my.custom', undefined, { timestamp: 0, location: { country: 'DE' } }) + + expect(sent).toHaveLength(1) + expect(sent[0]?.autoProperties.$country.value.value).toBe('DE') + expect(sent[0]?.occurTime).toMatchObject({ seconds: 0n }) + }) }) diff --git a/src/track.test.ts b/src/track.test.ts index 91819af..8d787fd 100644 --- a/src/track.test.ts +++ b/src/track.test.ts @@ -1,6 +1,6 @@ import { uuidv7 } from 'uuidv7' import { describe, expect, it, vi } from 'vitest' -import { toEvent } from './track.js' +import { type EventLocation, toEvent } from './track.js' import { wellKnownSchemas } from './well-known-events.js' const SESSION = uuidv7() @@ -103,3 +103,143 @@ describe('toEvent occurTime', () => { warn.mockRestore() }) }) + +describe('toEvent location', () => { + const location = (loc: unknown) => + toEvent('my.custom', SESSION, 'user_1', undefined, { location: loc as EventLocation }) + const BASE_KEYS = ['$lib', '$platform', '$sdkVersion'] + + it('renders every location field as a geo auto-property', () => { + const ap = location({ + continent: 'EU', + country: 'DE', + region: 'Berlin', + city: ' Berlin ', + postalCode: '10115', + metroCode: '807', + timezone: 'Europe/Berlin', + latitude: 52.52, + longitude: 13.405, + })?.autoProperties + expect(ap?.$continent.value.value).toBe('EU') + expect(ap?.$country.value.value).toBe('DE') + expect(ap?.$region.value.value).toBe('Berlin') + expect(ap?.$city.value.value).toBe('Berlin') + expect(ap?.$postalCode.value.value).toBe('10115') + expect(ap?.$metroCode.value.value).toBe('807') + expect(ap?.$timezone.value.value).toBe('Europe/Berlin') + expect(ap?.$latitude.value.value).toBe(52.52) + expect(ap?.$longitude.value.value).toBe(13.405) + }) + + // A whole-number coordinate must not land in the int slot: every other writer of these + // keys uses Float64, and the two are different ClickHouse Variant slots. + it('always renders coordinates as doubleValue', () => { + const e = location({ latitude: 52, longitude: 13 }) + expect(e?.autoProperties.$latitude.value.case).toBe('doubleValue') + expect(e?.autoProperties.$longitude.value.case).toBe('doubleValue') + }) + + it('accepts coordinates at the poles and the antimeridian', () => { + const e = location({ latitude: -90, longitude: 180 }) + expect(e?.autoProperties.$latitude.value.value).toBe(-90) + expect(e?.autoProperties.$longitude.value.value).toBe(180) + }) + + it('bounds latitude at 90 and longitude at 180', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(location({ latitude: 100, longitude: 13 })?.autoProperties.$latitude).toBeUndefined() + expect(location({ latitude: 45, longitude: 100 })?.autoProperties.$longitude.value.value).toBe(100) + warn.mockRestore() + }) + + // A lone coordinate reads as a real position with the other axis at 0. + it('drops a coordinate that has lost its pair', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ latitude: 52.52, city: 'Berlin' }) + expect(e?.autoProperties.$latitude).toBeUndefined() + expect(e?.autoProperties.$city.value.value).toBe('Berlin') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be set together')) + warn.mockRestore() + }) + + it('normalizes country case and spacing', () => { + expect(location({ country: ' de ' })?.autoProperties.$country.value.value).toBe('DE') + }) + + it('drops a country outside the ISO set with a warning, keeping the rest', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ country: 'XX', city: 'Berlin' }) + expect(e?.autoProperties.$country).toBeUndefined() + expect(e?.autoProperties.$city.value.value).toBe('Berlin') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('ISO 3166-1')) + warn.mockRestore() + }) + + // The slot follows the declared field, not the runtime value: a JS caller (or a parsed + // request body) that sends a number for a string field would otherwise file it in the + // wrong Variant slot, and skip the country check entirely. + it('drops a wrong-typed field with a warning, keeping the event', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ country: 49, latitude: '52', city: 'Berlin' }) + expect(e?.autoProperties.$country).toBeUndefined() + expect(e?.autoProperties.$latitude).toBeUndefined() + expect(e?.autoProperties.$city.value.value).toBe('Berlin') + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + // TypeScript only catches this on a fresh literal, and this option exists to carry values + // out of a parsed request body. + it('warns about a key that is not a location field', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ country: 'DE', citty: 'Berlin' }) + expect(e?.autoProperties.$country.value.value).toBe('DE') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown location field "citty"')) + warn.mockRestore() + }) + + it('warns when location is not an object', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location('Berlin') + expect(Object.keys(e?.autoProperties ?? {}).sort()).toEqual(BASE_KEYS) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('expected an object')) + warn.mockRestore() + }) + + // A null field is "unknown", not a mistake: it must cost the field, never the event. + it('treats a null field as absent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ country: 'DE', city: null }) + expect(e?.autoProperties.$country.value.value).toBe('DE') + expect(e?.autoProperties.$city).toBeUndefined() + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('omits empty, out-of-range and non-finite values', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ city: ' ', latitude: Number.NaN, longitude: 200, country: 'DE' }) + expect(e?.autoProperties.$city).toBeUndefined() + expect(e?.autoProperties.$latitude).toBeUndefined() + expect(e?.autoProperties.$longitude).toBeUndefined() + expect(e?.autoProperties.$country.value.value).toBe('DE') + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + // Silence here would look identical to sending no location at all, while the event ships + // with no geo whatsoever — nothing on the server fills it in. + it('warns when no field is usable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ city: '', country: '' }) + expect(Object.keys(e?.autoProperties ?? {}).sort()).toEqual(BASE_KEYS) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no usable fields')) + warn.mockRestore() + }) + + it('writes no geo keys when no location is given', () => { + const e = toEvent('my.custom', SESSION, 'user_1') + expect(Object.keys(e?.autoProperties ?? {}).sort()).toEqual(BASE_KEYS) + }) +}) diff --git a/src/track.ts b/src/track.ts index 4c100d0..34e9e05 100644 --- a/src/track.ts +++ b/src/track.ts @@ -7,9 +7,16 @@ import { type PropertyValue, PropertyValueSchema } from './gen/common/v1/propert import { type Event, EventSchema } from './gen/sdk/events/v1/events_pb.js' import { log } from './logger.js' import { SDK_VERSION } from './version.js' -import { type JsonValue, type TrackOptions, type WellKnownEventName, wellKnownSchemas } from './well-known-events.js' +import { + type EventLocation, + type JsonValue, + type TrackOptions, + type WellKnownEventName, + wellKnownSchemas, +} from './well-known-events.js' export type { + EventLocation, JsonValue, TrackFn, TrackOptions, @@ -231,12 +238,7 @@ const mapPropsViaHeuristic = ( // certainly a unit mistake — seconds or microseconds passed where epoch milliseconds are expected. const MAX_OCCUR_TIME_MS = 253_402_300_799_000 -/** - * Resolves an event's occurrence time. An explicit `timestamp` is honored only when it is a - * non-negative integer epoch-millisecond value within the proto Timestamp range — so `0` (the Unix - * epoch) is preserved, while a negative, fractional, or out-of-range value is logged and falls back - * to the current time rather than silently producing a bogus time or throwing in `timestampFromMs`. - */ +/** `0` (the Unix epoch) is a real timestamp; anything out of range falls back rather than throwing. */ const resolveOccurTime = (timestamp?: number) => { if (timestamp === undefined) { return timestampNow() @@ -248,6 +250,118 @@ const resolveOccurTime = (timestamp?: number) => { return timestampNow() } +type LocationFieldType = NonNullable extends number ? 'number' : NonNullable extends string ? 'string' : never + +const LOCATION_FIELDS: { readonly [K in keyof EventLocation]-?: LocationFieldType } = { + continent: 'string', + country: 'string', + region: 'string', + city: 'string', + postalCode: 'string', + metroCode: 'string', + timezone: 'string', + latitude: 'number', + longitude: 'number', +} + +const LOCATION_FIELD_NAMES = Object.keys(LOCATION_FIELDS).join(', ') + +// Mirrors internal/geo/countries.go. The server stores $country unvalidated and only the +// choropleth filters it, so a bad code is aggregated into a permanent rollup dimension and +// then simply missing from the map. "XX" — Cloudflare's unknown — passes a two-letter check. +const COUNTRY_CODES = new Set( + ( + 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ ' + + 'BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR ' + + 'CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR ' + + 'GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ' + + 'ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ ' + + 'LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ ' + + 'MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF ' + + 'PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI ' + + 'SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR ' + + 'TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW' + ).split(' '), +) + +// jsValueToPropertyValue files a whole number as an int, a different Variant slot from the +// Float64 one every other writer of $latitude/$longitude uses. +const makeDoubleValue = (value: number): PropertyValue => + create(PropertyValueSchema, { value: { case: 'doubleValue', value } }) + +/** + * Renders TrackOptions.location as geo auto-properties. The server skips geo enrichment on + * private-key requests, which is all this SDK makes, so these keys are the only geo the event + * will ever carry — a dropped field is a permanent hole, not one filled in later. + */ +const locationProps = (location: EventLocation | undefined, kind: string): Record => { + const props: Record = {} + if (location === undefined || location === null) { + return props + } + if (typeof location !== 'object' || Array.isArray(location)) { + const got = Array.isArray(location) ? 'array' : typeof location + log.warn(`Ignoring location on event "${kind}": expected an object, got ${got}`) + return props + } + + for (const key of Object.keys(location)) { + if (!(key in LOCATION_FIELDS)) { + log.warn(`Ignoring unknown location field "${key}" on event "${kind}"; expected one of ${LOCATION_FIELD_NAMES}`) + } + } + + const coords: { latitude?: number; longitude?: number } = {} + for (const [field, expected] of Object.entries(LOCATION_FIELDS) as [keyof EventLocation, 'string' | 'number'][]) { + const value = location[field] + if (value === undefined || value === null) { + continue + } + if (typeof value !== expected) { + log.warn(`Ignoring location.${field} on event "${kind}": expected a ${expected}, got ${typeof value}`) + continue + } + if (typeof value === 'number') { + const limit = field === 'latitude' ? 90 : 180 + if (!Number.isFinite(value) || Math.abs(value) > limit) { + log.warn(`Ignoring location.${field} ${value} on event "${kind}"; expected a number within ±${limit}`) + continue + } + coords[field as 'latitude' | 'longitude'] = value + continue + } + const trimmed = value.trim() + if (trimmed === '') { + continue + } + if (field === 'country') { + const code = trimmed.toUpperCase() + if (!COUNTRY_CODES.has(code)) { + log.warn( + `Ignoring location.country "${value}" on event "${kind}"; expected an ISO 3166-1 alpha-2 code like "DE"`, + ) + continue + } + props.$country = makeStringValue(code) + continue + } + props[`$${field}`] = makeStringValue(trimmed) + } + + // A lone coordinate reads as a real position with the other axis at 0, so send both or neither. + if (coords.latitude !== undefined && coords.longitude !== undefined) { + props.$latitude = makeDoubleValue(coords.latitude) + props.$longitude = makeDoubleValue(coords.longitude) + } else if (coords.latitude !== undefined || coords.longitude !== undefined) { + log.warn(`Ignoring location coordinates on event "${kind}": latitude and longitude must be set together`) + } + + if (Object.keys(props).length === 0) { + log.warn(`Ignoring location on event "${kind}": no usable fields; the event will carry no geo`) + } + return props +} + /** * Builds and validates an Event for ingestion. Returns null (and logs) on any validation * failure so the caller can drop the event without throwing. @@ -281,6 +395,7 @@ export const toEvent = ( $lib: makeStringValue('pug-node'), $sdkVersion: makeStringValue(SDK_VERSION), $platform: makeStringValue('server'), + ...locationProps(opts?.location, kind), }, customProperties, kind, diff --git a/src/well-known-events.ts b/src/well-known-events.ts index 83489f9..f6bfdd7 100644 --- a/src/well-known-events.ts +++ b/src/well-known-events.ts @@ -1,13 +1,38 @@ import type { JsonValue, MessageInitShape } from '@bufbuild/protobuf' import { wellKnownSchemas } from './well-known-events.generated.js' +/** + * Where the visitor is, for an event your backend sends on their behalf. + * + * Pug only derives geo from CDN headers on browser (public-key) requests. This SDK uses a + * private key, so the server adds no geo of its own: an event carries what you set here, or + * none at all. An unusable field is dropped with a warning; the rest is still sent. + */ +export interface EventLocation { + readonly continent?: string + /** ISO 3166-1 alpha-2, e.g. `DE`. Case and surrounding spaces are normalized. */ + readonly country?: string + readonly region?: string + readonly city?: string + readonly postalCode?: string + readonly metroCode?: string + /** IANA name, e.g. `Europe/Berlin`. */ + readonly timezone?: string + /** Decimal degrees; only sent when paired with `longitude`. */ + readonly latitude?: number + /** Decimal degrees; only sent when paired with `latitude`. */ + readonly longitude?: number +} + /** * Options passed to `track()`. `timestamp` overrides the default current time (epoch - * milliseconds); `sessionId` overrides the client's default per-instance session id. + * milliseconds); `sessionId` overrides the client's default per-instance session id; + * `location` records the visitor's location, which a server SDK's requests cannot reveal. */ export interface TrackOptions { readonly timestamp?: number readonly sessionId?: string + readonly location?: EventLocation } export type { JsonValue } From 5c3ec67b6d3c8a458629512a7c4af66cd22f4ce5 Mon Sep 17 00:00:00 2001 From: Praveen Kumar Date: Sun, 20 Sep 2026 14:16:11 +0530 Subject: [PATCH 2/4] Use an own-property check for unknown location fields --- src/track.test.ts | 9 +++++++++ src/track.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/track.test.ts b/src/track.test.ts index 8d787fd..f601825 100644 --- a/src/track.test.ts +++ b/src/track.test.ts @@ -199,6 +199,15 @@ describe('toEvent location', () => { warn.mockRestore() }) + // `in` would match these off Object.prototype and skip the warning. + it('warns about a key that shadows a prototype member', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const e = location({ country: 'DE', toString: 'Berlin' }) + expect(e?.autoProperties.$country.value.value).toBe('DE') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown location field "toString"')) + warn.mockRestore() + }) + it('warns when location is not an object', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const e = location('Berlin') diff --git a/src/track.ts b/src/track.ts index 34e9e05..004b426 100644 --- a/src/track.ts +++ b/src/track.ts @@ -306,7 +306,7 @@ const locationProps = (location: EventLocation | undefined, kind: string): Recor } for (const key of Object.keys(location)) { - if (!(key in LOCATION_FIELDS)) { + if (!Object.hasOwn(LOCATION_FIELDS, key)) { log.warn(`Ignoring unknown location field "${key}" on event "${kind}"; expected one of ${LOCATION_FIELD_NAMES}`) } } From 9156304bba841880f69bfac89b44526406d4812b Mon Sep 17 00:00:00 2001 From: Praveen Kumar Date: Sun, 20 Sep 2026 14:16:11 +0530 Subject: [PATCH 3/4] Fix prototype-named event kinds throwing out of toEvent --- src/track.test.ts | 8 ++++++++ src/track.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/track.test.ts b/src/track.test.ts index f601825..986817c 100644 --- a/src/track.test.ts +++ b/src/track.test.ts @@ -80,6 +80,14 @@ describe('well-known events', () => { }) }) +// A kind matching an Object.prototype member would otherwise resolve to the inherited +// function and throw out of toEvent, which is documented to return null instead. +it('treats a prototype-named kind as a custom event', () => { + const e = toEvent('toString', SESSION, 'user_1', { a: 1 }) + expect(e?.kind).toBe('toString') + expect(e?.customProperties.a.value.value).toBe(1n) +}) + describe('toEvent occurTime', () => { it('honors an explicit epoch-millisecond timestamp', () => { const e = toEvent('my.custom', SESSION, 'user_1', {}, { timestamp: 1_700_000_000_000 }) diff --git a/src/track.ts b/src/track.ts index 004b426..0a026d3 100644 --- a/src/track.ts +++ b/src/track.ts @@ -26,7 +26,7 @@ export type { const validator = createValidator() -const isWellKnownEvent = (kind: string): kind is WellKnownEventName => kind in wellKnownSchemas +const isWellKnownEvent = (kind: string): kind is WellKnownEventName => Object.hasOwn(wellKnownSchemas, kind) /** Renders a protovalidate failure result as a single human-readable string for logging. */ export const formatValidationError = (result: ReturnType): string => From 2d8e88d3ab581c21fa9b695aac47c53f2018f407 Mon Sep 17 00:00:00 2001 From: Praveen Kumar Date: Sun, 20 Sep 2026 14:37:35 +0530 Subject: [PATCH 4/4] Generate version.ts with bun --print in prebuild Matches sdk-web, and the repo already builds on bun. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35c059e..9f0a4ca 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "access": "public" }, "scripts": { - "prebuild": "echo \"export const SDK_VERSION = '$(node -p \"require('./package.json').version\")'\" > src/version.ts", + "prebuild": "echo \"export const SDK_VERSION = '$(bun --print \"require('./package.json').version\")'\" > src/version.ts", "build": "tsc", "prepublishOnly": "npm run build", "watch": "tsc -w",