Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 12 additions & 0 deletions src/pug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { value: { value: unknown } }>; 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 })
})
})
159 changes: 158 additions & 1 deletion src/track.test.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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 })
Expand All @@ -103,3 +111,152 @@ 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()
})

// `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')
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)
})
})
Loading
Loading