Skip to content
Open
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
4 changes: 3 additions & 1 deletion compliance/node/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ The startup JSON line reports the actual bound address and port (`PORT=0` is sup

## Public operations and isolation

`binding.mjs` implements setup, capture, AI capture, flush, feature-flag lookup and reload. It renames explicit parameters without changing SDK defaults, preserves absence/null/false/zero, and distinguishes native void, undefined, JSON values and thrown exceptions. Unsupported translations and values without a lossless JSON representation are harness failures, not fabricated SDK results. Timestamps map to `Date` only when lossless. Numeric tokens that round during parsing, exceed the safe-integer range, or lose negative zero through JSON transport produce attributed fixture failures before any SDK call. Invalid representable field values still reach the SDK.
`binding.mjs` implements setup, capture, AI capture, identify, alias, flush, feature-flag lookup and reload. It renames explicit parameters without changing SDK defaults, preserves absence/null/false/zero, and distinguishes native void, undefined, JSON values and thrown exceptions. Unsupported translations and values without a lossless JSON representation are harness failures, not fabricated SDK results. Timestamps map to `Date` only when lossless. Numeric tokens that round during parsing, exceed the safe-integer range, or lose negative zero through JSON transport produce attributed fixture failures before any SDK call. Invalid representable field values still reach the SDK.

`/identify` maps `distinct_id` to `distinctId` and an explicitly supplied `set` to `properties.$set`, preserving literal user-property keys. `/alias` maps `distinct_id` to `distinctId` and passes `alias` unchanged. Both accept optional `disable_geoip` as `disableGeoip`, call only the corresponding public method, and classify its native undefined return as void. Other fields are unsupported. Delivery is observed after an explicit `/flush`; select the identify/alias specs explicitly in the harness.

Each fixture gets a fresh child process and SDK receiver. Requests are bounded to at most 60 seconds. Public `shutdown()` closes a fixture; deadline or process failures kill that isolated process and fail the request. Closed fixture IDs and call IDs cannot be reused. No queue/cache state or private evaluator hook is read or mutated. The advertised `storage.empty.v1` means fresh case isolation, not a storage-control API.

Expand Down
30 changes: 28 additions & 2 deletions compliance/node/v2/binding.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
// Native signatures are pinned to posthog-node 5.52.4 (see README.md).
import { randomUUID } from 'node:crypto'

export const routes = ['/setup', '/capture', '/capture_ai', '/flush', '/get_feature_flag', '/reload_feature_flags']
export const routes = [
'/setup',
'/capture',
'/capture_ai',
'/identify',
'/alias',
'/flush',
'/get_feature_flag',
'/reload_feature_flags',
]
export const failure = (kind, code, message) => ({ kind: 'harness', failure: { kind, code, message } })
class BindingGap extends Error {
constructor(kind, code, message) {
Expand Down Expand Up @@ -194,6 +203,19 @@ export class Binding {
`${route}/send_feature_flags`
)
result = route === '/capture' ? this.client.capture(mapped) : this.client.captureAi(mapped)
} else if (route === '/identify') {
const mapped = rename(
args,
{ distinct_id: 'distinctId', set: 'properties', disable_geoip: 'disableGeoip' },
route
)
// `set` contains literal user properties, including any reserved-looking keys.
if (own(args, 'set')) mapped.properties = { $set: args.set }
result = this.client.identify(mapped)
} else if (route === '/alias') {
result = this.client.alias(
rename(args, { distinct_id: 'distinctId', alias: 'alias', disable_geoip: 'disableGeoip' }, route)
)
} else if (route === '/flush') {
checkKeys(args, [], route)
result = await this.client.flush()
Expand All @@ -220,7 +242,11 @@ export class Binding {
kind: 'sdk',
outcome: classify(
result,
route === '/capture' || route === '/flush' || route === '/reload_feature_flags'
route === '/capture' ||
route === '/identify' ||
route === '/alias' ||
route === '/flush' ||
route === '/reload_feature_flags'
),
}
} catch (error) {
Expand Down
72 changes: 71 additions & 1 deletion compliance/node/v2/binding.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ async function spies(run, captureMode = 'v0') {
const results = {
capture: undefined,
captureAi: undefined,
identify: undefined,
alias: undefined,
flush: undefined,
getFeatureFlag: undefined,
reloadFeatureFlags: undefined,
Expand Down Expand Up @@ -159,6 +161,71 @@ test('AI capture invokes its public method once and preserves native results and
}
})

test('identify and alias preserve exact public arguments, omission, native void and failures', async () => {
for (const mode of ['v0', 'v1']) {
await spies(async (binding, calls, results) => {
for (const route of ['/identify', '/alias']) {
assert.equal((await binding.invoke(route, {})).failure.code, 'before-setup')
}
await setup(binding)
const set = {
active: false,
score: 0,
note: null,
preferences: { theme: 'dark' },
tags: ['beta', 'team'],
$set: { literal: true },
$set_once: { literal: false },
$anon_distinct_id: 'literal',
}
const cases = [
['/identify', { distinct_id: 'person', set }, { distinctId: 'person', properties: { $set: set } }],
['/identify', { distinct_id: 'person' }, { distinctId: 'person' }],
['/identify', {}, {}],
['/alias', { distinct_id: 'previous', alias: 'person' }, { distinctId: 'previous', alias: 'person' }],
['/alias', { distinct_id: 'previous' }, { distinctId: 'previous' }],
['/alias', { alias: 'person' }, { alias: 'person' }],
['/alias', {}, {}],
]
for (const value of [false, 0, null, '', {}]) {
cases.push([
'/identify',
{ distinct_id: value, set: value, disable_geoip: value },
{ distinctId: value, properties: { $set: value }, disableGeoip: value },
])
cases.push([
'/alias',
{ distinct_id: value, alias: value, disable_geoip: value },
{ distinctId: value, alias: value, disableGeoip: value },
])
}
for (const [route, args, expected] of cases) {
const before = structuredClone(args)
assert.deepEqual(await binding.invoke(route, args), { kind: 'sdk', outcome: { kind: 'void' } })
assert.deepEqual(calls.at(-1), [route.slice(1), [expected]])
assert.deepEqual(args, before)
}
assert.equal(calls.length, cases.length + 1)
for (const name of ['identify', 'alias']) {
for (const value of [false, 0, null]) {
results[name] = value
assert.deepEqual(await binding.invoke(`/${name}`, {}), {
kind: 'sdk',
outcome: { kind: 'value', value },
})
}
results[name] = new Error(`native ${name} failure`)
const thrown = await binding.invoke(`/${name}`, {})
assert.equal(thrown.kind, 'sdk')
assert.equal(thrown.outcome.kind, 'thrown')
assert.equal(thrown.outcome.error.kind, 'exception')
assert.deepEqual(await binding.invoke(`/${name}`, {}), thrown)
}
assert.equal(calls.filter(([name]) => ['capture', 'captureAi', 'flush'].includes(name)).length, 0)
}, mode)
}
})

test('semantic negatives reach public constructor/capture without coercion', async () => {
await spies(async (binding, calls) => {
const result = await binding.invoke('/setup', { project_token: 'test-project', config: null })
Expand Down Expand Up @@ -312,7 +379,10 @@ test('unsupported supplied fields remain attributed gaps before native work', as
['/flush', { timeout_ms: 0 }],
['/get_feature_flag', { key: 'f', fresh: false }],
['/get_feature_flag', { default_value: null }],
['/identify', {}],
['/identify', { properties: {} }],
['/identify', { set_once: null }],
['/alias', { properties: {} }],
['/alias', { set: false }],
])
assert.equal((await binding.invoke(route, args)).failure.kind, 'unsupported_binding')
assert.equal((await setup(binding)).failure.code, 'repeated-setup')
Expand Down
71 changes: 71 additions & 0 deletions compliance/node/v2/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,77 @@ async function harness(t, options = {}) {

for (const mode of ['v0', 'v1'])
for (const format of ['cjs', 'esm']) {
test(`public ${format}/${mode}: identify and alias deliver literal properties after flush`, async (t) => {
const traffic = []
const mock = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += chunk
traffic.push({ path: request.url, body: JSON.parse(body) })
response.setHeader('content-type', 'application/json')
response.end('{"status":1}')
})
mock.listen(0, '127.0.0.1')
await once(mock, 'listening')
t.after(
() =>
new Promise((done) => {
mock.closeAllConnections()
mock.close(done)
})
)
const { post, allocate, invoke, close } = await harness(t, { mode, format })
const negotiation = await post('negotiate', { protocol })
assert.ok(negotiation.supported_routes.includes('/identify'))
assert.ok(negotiation.supported_routes.includes('/alias'))
await allocate()
await invoke('/setup', {
project_token: 'phc_test',
config: {
host: `http://127.0.0.1:${mock.address().port}`,
compression: 'none',
flush_at: 20,
flush_interval_ms: 0,
},
})
const set = {
email: 'user@example.test',
active: false,
score: 0,
note: null,
preferences: { theme: 'dark' },
tags: ['beta', 'team'],
$set: { literal: true },
$set_once: { literal: false },
$anon_distinct_id: 'literal',
}
for (const [route, args, expectedEvent, distinctId] of [
['/identify', { distinct_id: 'user-123', set, disable_geoip: false }, '$identify', 'user-123'],
[
'/alias',
{ distinct_id: 'anon-123', alias: 'user-123', disable_geoip: false },
'$create_alias',
'anon-123',
],
]) {
const count = traffic.length
assert.deepEqual(await invoke(route, args), { kind: 'sdk', outcome: { kind: 'void' } })
assert.equal(traffic.length, count)
assert.deepEqual(await invoke('/flush'), { kind: 'sdk', outcome: { kind: 'void' } })
assert.equal(traffic.length, count + 1)
const request = traffic.at(-1)
assert.ok(request.path.startsWith(mode === 'v0' ? '/batch' : '/i/v1/analytics/events'))
assert.equal(request.body.batch.length, 1)
const event = request.body.batch[0]
assert.equal(event.event, expectedEvent)
assert.equal(event.distinct_id, distinctId)
assert.equal(event.properties.$geoip_disable, undefined)
if (route === '/identify') assert.deepEqual(event.properties.$set, set)
else assert.equal(event.properties.alias, 'user-123')
}
await close()
assert.equal(traffic.length, 2)
})

test(`public ${format}/${mode}: capture, flush, local results and reload through HTTP`, async (t) => {
const traffic = []
let active = true
Expand Down
Loading