diff --git a/openapi/capture.yaml b/openapi/capture.yaml index 85d7d6e595d..8525825f7d2 100644 --- a/openapi/capture.yaml +++ b/openapi/capture.yaml @@ -245,10 +245,42 @@ components: format: uuid type: type: string + $set: + $ref: '#/components/schemas/SetPersonProperties' + $set_once: + $ref: '#/components/schemas/SetOncePersonProperties' properties: type: object + properties: + $set: + $ref: '#/components/schemas/SetPersonProperties' + $set_once: + $ref: '#/components/schemas/SetOncePersonProperties' additionalProperties: true + SetPersonProperties: + type: object + description: | + Set person property to a given values. If the property does not + exist, it will be set. If the property already exists, it + will be updated to the new value. The type of the property + will be inferred from the value. + additionalProperties: true + example: + $set: + country: UK + city: Cambridge + + SetOncePersonProperties: + type: object + description: | + Set person property to a given value, but only if it is not + currently set. It will not override existing values. The type + of the property will be inferred from the value. + example: + $set_once: + initial_referrer: https://google.com + EventCaptureRequest: $ref: '#/components/schemas/Event' diff --git a/plugin-server/functional_tests/api.ts b/plugin-server/functional_tests/api.ts index b87af62c4ec..50c21f60879 100644 --- a/plugin-server/functional_tests/api.ts +++ b/plugin-server/functional_tests/api.ts @@ -57,6 +57,8 @@ export const capture = async ({ sentAt = new Date(), eventTime = new Date(), now = new Date(), + $set = undefined, + $set_once = undefined, topic = ['$performance_event', '$snapshot'].includes(event) ? 'session_recording_events' : 'events_plugin_ingestion', @@ -71,6 +73,8 @@ export const capture = async ({ eventTime?: Date now?: Date topic?: string + $set?: object + $set_once?: object }) => { // WARNING: this capture method is meant to simulate the ingestion of events // from the capture endpoint, but there is no guarantee that is is 100% @@ -92,6 +96,8 @@ export const capture = async ({ properties: { ...properties, uuid }, team_id: teamId, timestamp: eventTime, + $set, + $set_once, }), }) ), diff --git a/plugin-server/functional_tests/ingestion.test.ts b/plugin-server/functional_tests/ingestion.test.ts index 25c90d60ccd..185f4e06d25 100644 --- a/plugin-server/functional_tests/ingestion.test.ts +++ b/plugin-server/functional_tests/ingestion.test.ts @@ -202,6 +202,37 @@ test.concurrent(`event ingestion: can $set and update person properties`, async }) }) +test.concurrent(`event ingestion: can $set and update person properties with top level $set`, async () => { + // We support $set at the top level. This is as the time of writing how the + // posthog-js library works. + const teamId = await createTeam(organizationId) + const distinctId = new UUIDT().toString() + + await capture({ + teamId, + distinctId, + uuid: new UUIDT().toString(), + event: '$identify', + properties: { + distinct_id: distinctId, + }, + $set: { prop: 'value' }, + }) + + const firstUuid = new UUIDT().toString() + await capture({ teamId, distinctId, uuid: firstUuid, event: 'custom event', properties: {} }) + await waitForExpect(async () => { + const [event] = await fetchEvents(teamId, firstUuid) + expect(event).toEqual( + expect.objectContaining({ + person_properties: expect.objectContaining({ + prop: 'value', + }), + }) + ) + }) +}) + test.concurrent(`event ingestion: person properties are point in event time`, async () => { const teamId = await createTeam(organizationId) const distinctId = new UUIDT().toString() @@ -301,6 +332,42 @@ test.concurrent(`event ingestion: can $set_once person properties but not update }) }) +test.concurrent( + `event ingestion: can $set_once person properties but not update, with top level $set_once`, + async () => { + // We support $set_once at the top level. This is as the time of writing + // how the posthog-js library works. + const teamId = await createTeam(organizationId) + const distinctId = new UUIDT().toString() + + const personEventUuid = new UUIDT().toString() + await capture({ + teamId, + distinctId, + uuid: personEventUuid, + event: '$identify', + properties: { + distinct_id: distinctId, + }, + $set_once: { prop: 'value' }, + }) + + const firstUuid = new UUIDT().toString() + await capture({ teamId, distinctId, uuid: firstUuid, event: 'custom event', properties: {} }) + await waitForExpect(async () => { + const [event] = await fetchEvents(teamId, firstUuid) + expect(event).toEqual( + expect.objectContaining({ + person_properties: { + $creator_event_uuid: personEventUuid, + prop: 'value', + }, + }) + ) + }) + } +) + test.concurrent(`event ingestion: events without a team_id get processed correctly`, async () => { const token = new UUIDT().toString() const teamId = await createTeam(organizationId, '', token)