diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts index eb97ea50d7a..5129a8ff310 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts @@ -2,6 +2,8 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' +import { API_VERSION, CANARY_API_VERSION, FLAGON_NAME } from '../utils' +import { toEpochMs } from '../v3' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' @@ -42,7 +44,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('customEvent', { event, settings, @@ -125,7 +127,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('customEvent', { event, settings, @@ -211,7 +213,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -294,7 +296,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -373,7 +375,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -450,7 +452,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -529,7 +531,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -609,7 +611,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -691,7 +693,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -773,7 +775,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -855,7 +857,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -907,4 +909,260 @@ describe('Reddit Conversions Api', () => { }) }) }) + + describe('API Version Feature Flag', () => { + const flagEvent = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-flag', + type: 'track', + userId: 'user_id_1', + properties: { + click_id: 'click_id_1', + currency: 'USD', + revenue: 100, + email: 'test@test.com' + }, + context: { + userAgent: 'test-user-agent', + ip: '111.111.111.111' + } + }) + + describe('standardEvent', () => { + it('should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + + it('performBatch should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testBatchAction('standardEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('performBatch should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testBatchAction('standardEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + }) + + describe('customEvent', () => { + it('should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + + it('performBatch should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testBatchAction('customEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('performBatch should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testBatchAction('customEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + }) + + describe('v3 payload shape', () => { + it('wraps events in data, renames type/metadata, uses epoch-ms + UPPER_SNAKE + action_source', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase', action_source: 'WEBSITE', event_metadata: { value_decimal: 100 } }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { + data: { partner: string; events: Record[] } + } + expect(body.data.partner).toBe('SEGMENT') + const event = body.data.events[0] as { + event_at: number + action_source: string + type: { tracking_type: string } + metadata: { value: number } + } + // epoch ms (timestamp = 2024-01-08T13:52:50.212Z) + expect(event.event_at).toBe(Date.parse(timestamp)) + expect(typeof event.event_at).toBe('number') + expect(event.action_source).toBe('WEBSITE') + expect(event.type.tracking_type).toBe('PURCHASE') + expect(event.metadata.value).toBe(100) + // v2-only keys must be gone + expect(event).not.toHaveProperty('event_type') + expect(event).not.toHaveProperty('event_metadata') + }) + + it('maps custom events to CUSTOM with custom_event_name inside type', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'My Custom Event', action_source: 'WEBSITE' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { data: { events: Record[] } } + const type = (body.data.events[0] as { type: { tracking_type: string; custom_event_name: string } }).type + expect(type.tracking_type).toBe('CUSTOM') + expect(type.custom_event_name).toBe('My Custom Event') + }) + + it('routes to Event Testing when test_id is set', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase', action_source: 'WEBSITE', test_id: 'test-123' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { data: { test_id?: string } } + expect(body.data.test_id).toBe('test-123') + }) + }) + + describe('toEpochMs (v3 event_at coercion)', () => { + it('accepts ISO 8601 strings', () => { + expect(toEpochMs('2024-01-08T13:52:50.212Z')).toBe(Date.parse('2024-01-08T13:52:50.212Z')) + }) + + it('accepts epoch milliseconds (number and 13-digit string)', () => { + expect(toEpochMs(1704721970212)).toBe(1704721970212) + expect(toEpochMs('1704721970212')).toBe(1704721970212) + }) + + it('rejects epoch seconds (10-digit) instead of silently misreading as ms', () => { + expect(() => toEpochMs(1704721970)).toThrow(/epoch milliseconds/) + expect(() => toEpochMs('1704721970')).toThrow(/epoch milliseconds/) + }) + + it('rejects non-integer numbers', () => { + expect(() => toEpochMs(1704721970212.5)).toThrow(/epoch milliseconds/) + }) + + it('rejects unparseable strings', () => { + expect(() => toEpochMs('not-a-date')).toThrow(/epoch milliseconds/) + }) + + it('rejects missing values', () => { + expect(() => toEpochMs(undefined)).toThrow(/required/) + expect(() => toEpochMs('')).toThrow(/required/) + }) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts index 4c5db62be13..999ba5d8166 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API v3. + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API v3. + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. + */ + action_source?: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. + */ + event_source_url?: string + /** + * A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3. + */ + test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts index b470347be1c..ed79bfa155c 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts @@ -11,7 +11,10 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,13 +29,16 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return await send(request, settings, [payload], features) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return await send(request, settings, payload, features) } } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts index 2d1e8cf9f28..d43d200cf1d 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -10,6 +10,38 @@ export const event_at: InputField = { } } +export const action_source: InputField = { + label: 'Action Source', + description: + 'The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.', + type: 'string', + required: false, + default: 'WEBSITE', + choices: [ + { label: 'Website', value: 'WEBSITE' }, + { label: 'App', value: 'APP' }, + { label: 'Offline (Physical Store)', value: 'PHYSICAL_STORE' }, + { label: 'Other', value: 'OTHER' } + ] +} + +export const event_source_url: InputField = { + label: 'Event Source URL', + description: + 'The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.', + type: 'string', + required: false, + default: { '@path': '$.context.page.url' } +} + +export const test_id: InputField = { + label: 'Test ID', + description: + 'A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.', + type: 'string', + required: false +} + export const custom_event_name: InputField = { label: 'Custom Event Name', description: @@ -292,6 +324,18 @@ export const products: InputField = { description: 'The name of the product. Optional.', type: 'string', required: false + }, + quantity: { + label: 'Quantity', + description: 'The number of this product in the event. Only applies to Reddit Conversions API v3.', + type: 'integer', + required: false + }, + item_price: { + label: 'Item Price', + description: 'The unit price of the product. Only applies to Reddit Conversions API v3.', + type: 'number', + required: false } }, default: { @@ -300,7 +344,9 @@ export const products: InputField = { { category: { '@path': '$.category' }, id: { '@path': '$.product_id' }, - name: { '@path': '$.name' } + name: { '@path': '$.name' }, + quantity: { '@path': '$.quantity' }, + item_price: { '@path': '$.price' } } ] } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts index 610b108d5d9..d90166a10b0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -26,6 +26,10 @@ const destination: DestinationDefinition = { type: 'password', required: true }, + // NOTE (v3 migration): `test_mode` is a v2.0-only mechanism. Reddit CAPI v3 replaces it + // with a per-request `test_id` field (see the customEvent/standardEvent action fields). + // This setting is only sent on the v2.0 path and should be deprecated/removed once v3 + // is promoted to stable and the feature flag is cleaned up. test_mode: { label: 'Test Mode', description: 'Indicates if events should be treated as test events by Reddit.', diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json index c8d2844593f..fe836bcefb0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -190,6 +190,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -268,6 +274,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API v3.", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API v3.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -2538,6 +2592,97 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "WEBSITE", + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + }, + { + "label": "Other", + "value": "OTHER" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "test_id": { + "label": "Test ID", + "description": "A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -2658,6 +2803,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -2736,6 +2887,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API v3.", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API v3.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -5006,6 +5205,97 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "WEBSITE", + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + }, + { + "label": "Other", + "value": "OTHER" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "test_id": { + "label": "Test ID", + "description": "A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -5065,6 +5355,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5147,6 +5443,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "PageVisit" }, "eventSlug": null @@ -5185,6 +5485,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5267,6 +5573,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "ViewContent" }, "eventSlug": null @@ -5305,6 +5615,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5387,6 +5703,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Search" }, "eventSlug": null @@ -5425,6 +5745,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5517,6 +5843,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToCart" }, "eventSlug": null @@ -5555,6 +5885,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5647,6 +5983,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToWishlist" }, "eventSlug": null @@ -5685,6 +6025,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5787,6 +6133,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Purchase" }, "eventSlug": null @@ -5825,6 +6175,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5914,6 +6270,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Lead" }, "eventSlug": null @@ -5952,6 +6312,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -6041,6 +6407,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "SignUp" }, "eventSlug": null diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts new file mode 100644 index 00000000000..ca020ac08c6 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts @@ -0,0 +1,75 @@ +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { processHashing } from '../../lib/hashing-utils' + +// Field types shared across both actions (standardEvent | customEvent). +export type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] +export type ProductsType = StandardEvent['products'] | CustomEvent['products'] +export type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] +export type DataProcessingOptionsType = + | StandardEvent['data_processing_options'] + | CustomEvent['data_processing_options'] +export type UserType = StandardEvent['user'] | CustomEvent['user'] +export type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] + +export function clean(str: string | undefined): string | undefined { + if (str === undefined || str === null || str === '') return undefined + return str.trim() +} + +export function cleanNum(num: number | undefined): number | undefined { + if (num === undefined || num === null) return undefined + return num +} + +export function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { + if (height === undefined || width === undefined) return undefined + return { height, width } +} + +export function canonicalizeEmail(value: string): string { + value = value.trim() + const localPartAndDomain = value.split('@') + const localPart = localPartAndDomain[0].replace(/\./g, '').split('+')[0] + return `${localPart.toLowerCase()}@${localPartAndDomain[1].toLowerCase()}` +} + +export const smartHash = ( + value: string | undefined, + cleaningFunction?: (value: string) => string +): string | undefined => { + if (value === undefined) return + return processHashing(value, 'sha256', 'hex', cleaningFunction) +} + +export function cleanPhoneNumber(phoneNumber: string): string { + if (!phoneNumber) return '' + phoneNumber = phoneNumber.trim() + const prefix = '+' + if (phoneNumber.startsWith('+')) { + phoneNumber = phoneNumber.slice(1) + } + // Remove any potential extensions from the number + const extensions = ['ext', 'x', 'anexo', '#', 'poste', 'int'] + const lower = phoneNumber.toLowerCase() + for (const keyword of extensions) { + const index = lower.indexOf(keyword) + if (index !== -1) { + phoneNumber = phoneNumber.slice(0, index) + break + } + } + // Add the prefix and remove all non-numeric characters + const digitsOnly = phoneNumber.replace(/\D/g, '') + return prefix + digitsOnly +} + +export function getAdId( + device_type?: string, + advertising_id?: string +): { [key: string]: string | undefined } | undefined { + if (!device_type) return undefined + if (!advertising_id) return undefined + const hashedAdId = smartHash(advertising_id) + return device_type === 'ios' ? { idfa: hashedAdId } : { aaid: hashedAdId } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts index 82d3f61f89b..42e8f636f53 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API v3. + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API v3. + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. + */ + action_source?: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. + */ + event_source_url?: string + /** + * A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3. + */ + test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts index a1209a791d4..7583d551947 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts @@ -11,7 +11,10 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,13 +29,16 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return await send(request, settings, [payload], features) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return await send(request, settings, payload, features) } } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts index e9a6ca9f5f2..e3622736d62 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -61,3 +61,58 @@ export interface StandardEventPayload { test_mode?: boolean partner: 'SEGMENT' } + +// ---- v3 (feature-flagged) ---- + +export interface V3Product extends Product { + quantity?: number + item_price?: number +} + +export interface V3DataProcessingOptions { + country?: string + modes?: string[] + region?: string +} + +export interface V3User { + idfa?: string + aaid?: string + email?: string + external_id?: string + ip_address?: string + user_agent?: string + uuid?: string + data_processing_options?: V3DataProcessingOptions + screen_dimensions?: { height?: number; width?: number } + phone_number?: string +} + +export interface V3Metadata { + currency?: string + item_count?: number + value?: number + conversion_id?: string + products?: V3Product[] +} + +export interface V3EventItem { + event_at: number + action_source: string + event_source_url?: string + click_id?: string + type: { + tracking_type: string + custom_event_name?: string + } + metadata?: V3Metadata + user?: V3User +} + +export interface V3Payload { + data: { + events: V3EventItem[] + partner: 'SEGMENT' + test_id?: string + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts index 4a6e6f45ad1..e245ebdcff7 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -1,197 +1,26 @@ -import type { RequestClient } from '@segment/actions-core' +import type { Features, RequestClient } from '@segment/actions-core' import type { Settings } from './generated-types' import type { Payload as StandardEvent } from './standardEvent/generated-types' import type { Payload as CustomEvent } from './customEvent/generated-types' -import { - StandardEventPayloadItem, - StandardEventPayload, - User, - Product, - EventMetadata, - DatapProcessingOptions -} from './types' -import { processHashing } from '../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' - -type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] -type ProductsType = StandardEvent['products'] | CustomEvent['products'] -type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] -type DataProcessingOptionsType = StandardEvent['data_processing_options'] | CustomEvent['data_processing_options'] -type UserType = StandardEvent['user'] | CustomEvent['user'] -type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] - -export async function send(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { - const data = createRedditPayload(payload, settings) - return request( - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${settings.ad_account_id}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${settings.conversion_token}` }, - json: JSON.parse(JSON.stringify(data)) - } - ) -} - -function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { - const payloadItems: StandardEventPayloadItem[] = [] - - payloads.forEach((payload) => { - const { - event_at, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id - } = payload - - const custom_event_name = (payload as CustomEvent).custom_event_name - const tracking_type = (payload as StandardEvent).tracking_type - - const payloadItem: StandardEventPayloadItem = { - event_at: event_at as string, - event_type: { - // if custom_event_name is present, tracking_type is 'Custom' - // if custom_event_name not present then we know the event is a StandardEvent - tracking_type: custom_event_name ? 'Custom' : tracking_type, - custom_event_name: clean(custom_event_name) - }, - click_id: clean(click_id), - event_metadata: getMetadata(event_metadata, products, conversion_id), - user: getUser(user, data_processing_options, screen_dimensions) - } - - payloadItems.push(payloadItem) - }) - - return { - events: payloadItems, - test_mode: settings.test_mode, - partner: 'SEGMENT' - } -} - -function clean(str: string | undefined): string | undefined { - if (str === undefined || str === null || str === '') return undefined - return str.trim() -} - -function cleanNum(num: number | undefined): number | undefined { - if (num === undefined || num === null) return undefined - return num -} - -function getProducts(products: ProductsType): Product[] | undefined { - if (!products) { - return undefined - } - - return products.map((product) => { - return { - category: clean(product.category), - id: clean(product.id), - name: clean(product.name) - } - }) -} - -function getMetadata( - metadata: EventMetadataType, - products: ProductsType, - conversion_id: ConversionIdType -): EventMetadata | undefined { - if (!metadata && !products && !conversion_id) { - return undefined - } - - return { - currency: clean(metadata?.currency), - item_count: cleanNum(metadata?.item_count), - value_decimal: cleanNum(metadata?.value_decimal), - products: getProducts(products), - conversion_id: smartHash(conversion_id, (value) => value.trim()) - } -} - -function getAdId(device_type?: string, advertising_id?: string): { [key: string]: string | undefined } | undefined { - if (!device_type) return undefined - if (!advertising_id) return undefined - const hashedAdId = smartHash(advertising_id) - return device_type === 'ios' ? { idfa: hashedAdId } : { aaid: hashedAdId } -} - -function getDataProcessingOptions( - dataProcessingOptions: DataProcessingOptionsType -): DatapProcessingOptions | undefined { - if (!dataProcessingOptions) return undefined - return { - country: clean(dataProcessingOptions.country), - modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), - region: clean(dataProcessingOptions.region) - } -} - -function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { - if (height === undefined || width === undefined) return undefined - return { - height, - width - } -} - -function getUser( - user: UserType, - dataProcessingOptions: DataProcessingOptionsType, - screenDimensions: ScreenDimensionsType -): User | undefined { - if (!user) return - - return { - ...getAdId(user.device_type, user.advertising_id), - email: smartHash(user.email, canonicalizeEmail), - external_id: smartHash(user.external_id, (value) => value.trim()), - ip_address: smartHash(user.ip_address, (value) => value.trim()), - user_agent: clean(user.user_agent), - uuid: clean(user.uuid), - data_processing_options: getDataProcessingOptions(dataProcessingOptions), - screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), - phone_number: smartHash(user.phone_number, cleanPhoneNumber) - } -} - -function canonicalizeEmail(value: string): string { - value = value.trim() - const localPartAndDomain = value.split('@') - const localPart = localPartAndDomain[0].replace(/\./g, '').split('+')[0] - return `${localPart.toLowerCase()}@${localPartAndDomain[1].toLowerCase()}` -} - -const smartHash = (value: string | undefined, cleaningFunction?: (value: string) => string): string | undefined => { - if (value === undefined) return - return processHashing(value, 'sha256', 'hex', cleaningFunction) -} - -function cleanPhoneNumber(phoneNumber: string): string { - if (!phoneNumber) return '' - phoneNumber = phoneNumber.trim() - const prefix = '+' - if (phoneNumber.startsWith('+')) { - phoneNumber = phoneNumber.slice(1) - } - // Remove any potential extensions from the number - const extensions = ['ext', 'x', 'anexo', '#', 'poste', 'int'] - const lower = phoneNumber.toLowerCase() - for (const keyword of extensions) { - const index = lower.indexOf(keyword) - if (index !== -1) { - phoneNumber = phoneNumber.slice(0, index) - break - } - } - // Add the prefix and remove all non-numeric characters - const digitsOnly = phoneNumber.replace(/\D/g, '') - - return prefix + digitsOnly +import { REDDIT_CONVERSIONS_API_VERSION, REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +import { sendV2 } from './v2' +import { sendV3 } from './v3' + +export const API_VERSION = REDDIT_CONVERSIONS_API_VERSION +export const CANARY_API_VERSION = REDDIT_CONVERSIONS_CANARY_API_VERSION +export const FLAGON_NAME = 'reddit-conversions-api-canary-version' + +export function isCanary(features?: Features): boolean { + return !!(features && features[FLAGON_NAME]) +} + +// Single flag fork: v3 (canary) or v2 (stable). Cleanup at flag removal = delete +// v2.ts, drop this branch, call sendV3 directly. +export async function send( + request: RequestClient, + settings: Settings, + payload: StandardEvent[] | CustomEvent[], + features?: Features +) { + return isCanary(features) ? sendV3(request, settings, payload) : sendV2(request, settings, payload) } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts new file mode 100644 index 00000000000..699b9410474 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts @@ -0,0 +1,144 @@ +import type { RequestClient } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { + StandardEventPayloadItem, + StandardEventPayload, + User, + Product, + EventMetadata, + DatapProcessingOptions +} from './types' +import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { + EventMetadataType, + ProductsType, + ConversionIdType, + DataProcessingOptionsType, + UserType, + ScreenDimensionsType, + clean, + cleanNum, + getScreen, + canonicalizeEmail, + smartHash, + cleanPhoneNumber, + getAdId +} from './shared' + +const V2_URL = (adAccountId: string) => + `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${adAccountId}` + +export async function sendV2(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { + const data = createRedditPayload(payload, settings) + return request(V2_URL(settings.ad_account_id), { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) +} + +function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { + const payloadItems: StandardEventPayloadItem[] = [] + + payloads.forEach((payload) => { + const { + event_at, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id + } = payload + + const custom_event_name = (payload as CustomEvent).custom_event_name + const tracking_type = (payload as StandardEvent).tracking_type + + const payloadItem: StandardEventPayloadItem = { + event_at: event_at as string, + event_type: { + // if custom_event_name is present, tracking_type is 'Custom' + // if custom_event_name not present then we know the event is a StandardEvent + tracking_type: custom_event_name ? 'Custom' : tracking_type, + custom_event_name: clean(custom_event_name) + }, + click_id: clean(click_id), + event_metadata: getMetadata(event_metadata, products, conversion_id), + user: getUser(user, data_processing_options, screen_dimensions) + } + + payloadItems.push(payloadItem) + }) + + return { + events: payloadItems, + test_mode: settings.test_mode, + partner: 'SEGMENT' + } +} + +function getProducts(products: ProductsType): Product[] | undefined { + if (!products) { + return undefined + } + + return products.map((product) => { + return { + category: clean(product.category), + id: clean(product.id), + name: clean(product.name) + } + }) +} + +function getMetadata( + metadata: EventMetadataType, + products: ProductsType, + conversion_id: ConversionIdType +): EventMetadata | undefined { + if (!metadata && !products && !conversion_id) { + return undefined + } + + return { + currency: clean(metadata?.currency), + item_count: cleanNum(metadata?.item_count), + value_decimal: cleanNum(metadata?.value_decimal), + products: getProducts(products), + conversion_id: smartHash(conversion_id, (value) => value.trim()) + } +} + +function getDataProcessingOptions( + dataProcessingOptions: DataProcessingOptionsType +): DatapProcessingOptions | undefined { + if (!dataProcessingOptions) return undefined + return { + country: clean(dataProcessingOptions.country), + modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), + region: clean(dataProcessingOptions.region) + } +} + +function getUser( + user: UserType, + dataProcessingOptions: DataProcessingOptionsType, + screenDimensions: ScreenDimensionsType +): User | undefined { + if (!user) return + + return { + ...getAdId(user.device_type, user.advertising_id), + email: smartHash(user.email, canonicalizeEmail), + external_id: smartHash(user.external_id, (value) => value.trim()), + ip_address: smartHash(user.ip_address, (value) => value.trim()), + user_agent: clean(user.user_agent), + uuid: clean(user.uuid), + data_processing_options: getDataProcessingOptions(dataProcessingOptions), + screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), + phone_number: smartHash(user.phone_number, cleanPhoneNumber) + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts new file mode 100644 index 00000000000..87500773429 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts @@ -0,0 +1,174 @@ +import type { RequestClient } from '@segment/actions-core' +import { PayloadValidationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +import { + EventMetadataType, + ProductsType, + ConversionIdType, + DataProcessingOptionsType, + UserType, + ScreenDimensionsType, + clean, + cleanNum, + getScreen, + canonicalizeEmail, + smartHash, + cleanPhoneNumber, + getAdId +} from './shared' +import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types' + +const V3_URL = (pixelId: string) => + `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_CANARY_API_VERSION}/pixels/${pixelId}/conversion_events` + +// v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. +const TRACKING_TYPE_V3: Record = { + PageVisit: 'PAGE_VISIT', + ViewContent: 'VIEW_CONTENT', + Search: 'SEARCH', + AddToCart: 'ADD_TO_CART', + AddToWishlist: 'ADD_TO_WISHLIST', + Purchase: 'PURCHASE', + Lead: 'LEAD', + SignUp: 'SIGN_UP', + Custom: 'CUSTOM' +} + +export async function sendV3(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { + const data = createRedditPayloadV3(payload) + return request(V3_URL(settings.ad_account_id), { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) +} + +function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Payload { + const test_id = clean(payloads[0]?.test_id) + + const events: V3EventItem[] = payloads.map((payload) => { + const { + event_at, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + action_source, + event_source_url + } = payload + + const custom_event_name = clean((payload as CustomEvent).custom_event_name) + const tracking_type = custom_event_name ? 'Custom' : (payload as StandardEvent).tracking_type + + if (!action_source) throw new PayloadValidationError('action_source is required') + + return { + event_at: toEpochMs(event_at), + action_source, + event_source_url: clean(event_source_url), + click_id: clean(click_id), + type: { + tracking_type: toV3TrackingType(tracking_type), + custom_event_name + }, + metadata: getMetadata(event_metadata, products, conversion_id), + user: getUser(user, data_processing_options, screen_dimensions) + } + }) + + return { data: { events, partner: 'SEGMENT', test_id } } +} + +// v3 requires event_at as an integer Unix epoch in milliseconds. We own the +// timestamp source (defaults to $.timestamp, an ISO string), so we accept ISO +// strings and epoch-ms; anything else is rejected rather than sent wrong. +// EPOCH_MS_MIN guards against epoch *seconds* being misread as ms (a 10-digit +// seconds value is < 1e12, so it's rejected instead of landing in 1970). +const EPOCH_MS_MIN = 1e12 +export function toEpochMs(value: string | number | undefined): number { + if (value === undefined || value === null || value === '') { + throw new PayloadValidationError('event_at is required') + } + // Already epoch milliseconds (number or numeric string), only if plausibly ms. + if (typeof value === 'number' && Number.isInteger(value) && value >= EPOCH_MS_MIN) return value + if (typeof value === 'string' && /^\d+$/.test(value.trim()) && Number(value.trim()) >= EPOCH_MS_MIN) { + return Number(value.trim()) + } + // ISO 8601 / RFC3339 string. + if (typeof value === 'string' && !/^\d+$/.test(value.trim())) { + const ms = Date.parse(value) + if (!Number.isNaN(ms)) return ms + } + throw new PayloadValidationError( + `event_at must be an ISO 8601 timestamp or epoch milliseconds, received: ${String(value)}` + ) +} + +function toV3TrackingType(tracking_type: string | undefined): string { + if (!tracking_type) throw new PayloadValidationError('tracking_type is required') + const mapped = TRACKING_TYPE_V3[tracking_type] + if (!mapped) throw new PayloadValidationError(`Unsupported tracking_type: ${tracking_type}`) + return mapped +} + +function getProducts(products: ProductsType): V3Product[] | undefined { + if (!products) return undefined + return products.map((product) => ({ + category: clean(product.category), + id: clean(product.id), + name: clean(product.name), + quantity: cleanNum(product.quantity), + item_price: cleanNum(product.item_price) + })) +} + +function getMetadata( + metadata: EventMetadataType, + products: ProductsType, + conversion_id: ConversionIdType +): V3Metadata | undefined { + if (!metadata && !products && !conversion_id) return undefined + return { + currency: clean(metadata?.currency), + item_count: cleanNum(metadata?.item_count), + value: cleanNum(metadata?.value_decimal), + products: getProducts(products), + conversion_id: smartHash(conversion_id, (value) => value.trim()) + } +} + +function getDataProcessingOptions( + dataProcessingOptions: DataProcessingOptionsType +): V3DataProcessingOptions | undefined { + if (!dataProcessingOptions) return undefined + return { + country: clean(dataProcessingOptions.country), + modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), + region: clean(dataProcessingOptions.region) + } +} + +function getUser( + user: UserType, + dataProcessingOptions: DataProcessingOptionsType, + screenDimensions: ScreenDimensionsType +): V3User | undefined { + if (!user) return + return { + ...getAdId(user.device_type, user.advertising_id), + email: smartHash(user.email, canonicalizeEmail), + external_id: smartHash(user.external_id, (value) => value.trim()), + ip_address: smartHash(user.ip_address, (value) => value.trim()), + user_agent: clean(user.user_agent), + uuid: clean(user.uuid), + data_processing_options: getDataProcessingOptions(dataProcessingOptions), + screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), + phone_number: smartHash(user.phone_number, cleanPhoneNumber) + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts index 8f6633b88ed..f53ab1e171e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts @@ -1,5 +1,13 @@ /** REDDIT_CONVERSIONS_API_VERSION - * Reddit conversions API version. + * Reddit conversions API version (stable/production). * API reference: https://ads-api.reddit.com/docs/v2/changelog */ export const REDDIT_CONVERSIONS_API_VERSION = 'v2.0' + +/** REDDIT_CONVERSIONS_CANARY_API_VERSION + * Reddit conversions API version (canary/feature-flagged). + * v3 is a breaking payload rewrite (new endpoint /api/v3/pixels/{pixel_id}/conversion_events, + * `data` envelope, renamed/retyped fields). See v3.ts for the transform. + * API reference: https://ads-api.reddit.com/docs/v3/capi-migration + */ +export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3'