From 266327bdfcb217dd314471e77eb78cfd0f48d38e Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 07:57:59 +0100 Subject: [PATCH 01/20] feat(reddit-conversions-api): add v3 support behind feature flag Adds Reddit Conversions API v3 support alongside the existing v2.0 implementation, gated behind the reddit-conversions-api-canary-version Flagon flag plus a new per-action api_version field (default Latest V3, falls back to Legacy V2 for existing configured customers who never set it). - New per-action fields: api_version, action_source (conditionally required only when api_version is v3, no default), event_source_url (defaults to $.context.page.url), test_id - products gains quantity/item_price sub-fields - event_metadata.value_decimal is sent as event_metadata.value on v3; event_type renames to type with UPPER_SNAKE_CASE tracking_type values - v3 request: POST /api/v3/pixels/{ad_account_id}/conversion_events, body wrapped in { data: { events, partner, test_id } }, event_at sent as epoch milliseconds - sendV2/sendV3 kept as fully separate, duplicated implementations (types-v2.ts/types-v3.ts, utils-v2.ts/utils-v3.ts) so v2 can be deleted outright once the migration is complete - Settings-level test_mode field removed (unused by any customer) Not included in this PR: replacing the 8 tracking_type presets with dedicated actions (Page Visit, Purchase, etc.) - deferred for now. Co-Authored-By: Claude Sonnet 5 --- .../__snapshots__/snapshot.test.ts.snap | 532 +++++++++++++++++- .../__tests__/index.test.ts | 225 ++++++-- .../__snapshots__/snapshot.test.ts.snap | 2 - .../customEvent/generated-types.ts | 24 + .../customEvent/index.ts | 31 +- .../reddit-conversions-api/fields.ts | 68 ++- .../reddit-conversions-api/generated-types.ts | 4 - .../reddit-conversions-api/index.ts | 110 +--- .../__snapshots__/snapshot.test.ts.snap | 2 - .../standardEvent/generated-types.ts | 24 + .../standardEvent/index.ts | 31 +- .../reddit-conversions-api/types-v2.ts | 51 ++ .../reddit-conversions-api/types-v3.ts | 58 ++ .../reddit-conversions-api/types.ts | 53 -- .../reddit-conversions-api/utils-v2.ts | 196 +++++++ .../reddit-conversions-api/utils-v3.ts | 234 ++++++++ .../reddit-conversions-api/utils.ts | 222 +------- .../reddit-conversions-api/versioning-info.ts | 10 +- 18 files changed, 1456 insertions(+), 421 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap index 82c1b98383a..f79ce65c4a4 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,5 +1,137 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Testing snapshot for actions-reddit-conversions-api destination: addToCart action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "p#r(0K#rxAAl(v)^z", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", + "currency": "SHP", + "item_count": 4702971690483712, + "products": Array [ + Object { + "category": "p#r(0K#rxAAl(v)^z", + "id": "p#r(0K#rxAAl(v)^z", + "name": "p#r(0K#rxAAl(v)^z", + }, + ], + "value_decimal": 47029716904837.12, + }, + "event_type": Object { + "tracking_type": "AddToCart", + }, + "user": Object { + "aaid": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", + "data_processing_options": Object { + "country": "RS", + "modes": Array [ + "LDU", + ], + "region": "p#r(0K#rxAAl(v)^z", + }, + "email": "a406eef9f73ae55d785d1835781c825452fa48024c9f8e1e4f5ec605431d28fb", + "external_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", + "ip_address": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", + "phone_number": "c101893d61ca9787edac49beedc0cb15dcc86c6495402b634963bcafdc7c925a", + "screen_dimensions": Object { + "height": 4702971690483712, + "width": 4702971690483712, + }, + "user_agent": "p#r(0K#rxAAl(v)^z", + "uuid": "p#r(0K#rxAAl(v)^z", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: addToCart action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "p#r(0K#rxAAl(v)^z", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", + }, + "event_type": Object { + "tracking_type": "AddToCart", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: addToWishlist action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "fuz[PT)N!tq14MWd#v", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", + "currency": "THB", + "item_count": 5745118342545408, + "products": Array [ + Object { + "category": "fuz[PT)N!tq14MWd#v", + "id": "fuz[PT)N!tq14MWd#v", + "name": "fuz[PT)N!tq14MWd#v", + }, + ], + "value_decimal": 57451183425454.08, + }, + "event_type": Object { + "tracking_type": "AddToWishlist", + }, + "user": Object { + "aaid": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", + "data_processing_options": Object { + "country": "SN", + "modes": Array [ + "LDU", + ], + "region": "fuz[PT)N!tq14MWd#v", + }, + "email": "f46bc5e064354c63dad112ee067db9299d4d5cdc296d65141031284d8069ded8", + "external_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", + "ip_address": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", + "phone_number": "517d3db2ccc6219d2d77a095f669ed393315267838df3a902cdabd7784f2d780", + "screen_dimensions": Object { + "height": 5745118342545408, + "width": 5745118342545408, + }, + "user_agent": "fuz[PT)N!tq14MWd#v", + "uuid": "fuz[PT)N!tq14MWd#v", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: addToWishlist action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "fuz[PT)N!tq14MWd#v", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", + }, + "event_type": Object { + "tracking_type": "AddToWishlist", + }, + }, + ], + "partner": "SEGMENT", +} +`; + exports[`Testing snapshot for actions-reddit-conversions-api destination: customEvent action - all fields 1`] = ` Object { "events": Array [ @@ -46,7 +178,6 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": true, } `; @@ -66,7 +197,336 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": true, +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: lead action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "WThh$KLW6%(Lw!jiO]q", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", + "currency": "UYW", + "item_count": 7322245504434176, + "products": Array [ + Object { + "category": "WThh$KLW6%(Lw!jiO]q", + "id": "WThh$KLW6%(Lw!jiO]q", + "name": "WThh$KLW6%(Lw!jiO]q", + }, + ], + "value_decimal": 73222455044341.77, + }, + "event_type": Object { + "tracking_type": "Lead", + }, + "user": Object { + "aaid": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", + "data_processing_options": Object { + "country": "TT", + "modes": Array [ + "LDU", + ], + "region": "WThh$KLW6%(Lw!jiO]q", + }, + "email": "2e4a86bd5a63a56435cb6d2baebf1a30a6b58e45a78d15fe567e66f1f81ce46d", + "external_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", + "ip_address": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", + "phone_number": "f6ea26ab26c3eb154996c9900ca7b9d960a9a35603310fb3b40b35f51d68e01b", + "screen_dimensions": Object { + "height": 7322245504434176, + "width": 7322245504434176, + }, + "user_agent": "WThh$KLW6%(Lw!jiO]q", + "uuid": "WThh$KLW6%(Lw!jiO]q", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: lead action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "WThh$KLW6%(Lw!jiO]q", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", + }, + "event_type": Object { + "tracking_type": "Lead", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: pageVisit action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "7B$qESc", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", + "currency": "CDF", + "item_count": -6062670935490560, + "products": Array [ + Object { + "category": "7B$qESc", + "id": "7B$qESc", + "name": "7B$qESc", + }, + ], + "value_decimal": -60626709354905.6, + }, + "event_type": Object { + "tracking_type": "PageVisit", + }, + "user": Object { + "aaid": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", + "data_processing_options": Object { + "country": "CF", + "modes": Array [ + "LDU", + ], + "region": "7B$qESc", + }, + "email": "4dc02b43a09caa28090093c65393bb6b8946576c1cbe0f8a6d1001543ceea5bd", + "external_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", + "ip_address": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", + "phone_number": "da91a9a6e4c52003a0781ce0a1ac648ab6e72df48de4f23c1d2984cf9e2e52c1", + "screen_dimensions": Object { + "height": -6062670935490560, + "width": -6062670935490560, + }, + "user_agent": "7B$qESc", + "uuid": "7B$qESc", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: pageVisit action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "7B$qESc", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", + }, + "event_type": Object { + "tracking_type": "PageVisit", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: purchase action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "KYja1m&ZYQ", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", + "currency": "IDR", + "item_count": -2400024885460992, + "products": Array [ + Object { + "category": "KYja1m&ZYQ", + "id": "KYja1m&ZYQ", + "name": "KYja1m&ZYQ", + }, + ], + "value_decimal": -24000248854609.92, + }, + "event_type": Object { + "tracking_type": "Purchase", + }, + "user": Object { + "aaid": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", + "data_processing_options": Object { + "country": "GU", + "modes": Array [ + "LDU", + ], + "region": "KYja1m&ZYQ", + }, + "email": "47ada54f61ac343e9be3c730c861d7c5615656ad9ec1b779a5f1590b24a43519", + "external_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", + "ip_address": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", + "phone_number": "e8c6111a6c5ce4814c99e9a0c8c576831793b3cb9e7d129f690b6f300ab9d600", + "screen_dimensions": Object { + "height": -2400024885460992, + "width": -2400024885460992, + }, + "user_agent": "KYja1m&ZYQ", + "uuid": "KYja1m&ZYQ", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: purchase action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "KYja1m&ZYQ", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", + }, + "event_type": Object { + "tracking_type": "Purchase", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: search action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "iWy&@8r4Oj28DP%5", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", + "currency": "SBD", + "item_count": 4106327638933504, + "products": Array [ + Object { + "category": "iWy&@8r4Oj28DP%5", + "id": "iWy&@8r4Oj28DP%5", + "name": "iWy&@8r4Oj28DP%5", + }, + ], + "value_decimal": 41063276389335.04, + }, + "event_type": Object { + "tracking_type": "Search", + }, + "user": Object { + "aaid": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", + "data_processing_options": Object { + "country": "PN", + "modes": Array [ + "LDU", + ], + "region": "iWy&@8r4Oj28DP%5", + }, + "email": "c8c37ddbecc769a0e7c7a1bff02a1f4a126b3b9135455dde3c20a059a45ec14a", + "external_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", + "ip_address": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", + "phone_number": "a216bca2681b91a26e7a31b540c7100e54ad93088280b3f5761012ed14e8d1cb", + "screen_dimensions": Object { + "height": 4106327638933504, + "width": 4106327638933504, + }, + "user_agent": "iWy&@8r4Oj28DP%5", + "uuid": "iWy&@8r4Oj28DP%5", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: search action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "iWy&@8r4Oj28DP%5", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", + }, + "event_type": Object { + "tracking_type": "Search", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: signUp action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "pB*l&&$y3eW4B", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", + "currency": "MDL", + "item_count": 562951232684032, + "products": Array [ + Object { + "category": "pB*l&&$y3eW4B", + "id": "pB*l&&$y3eW4B", + "name": "pB*l&&$y3eW4B", + }, + ], + "value_decimal": 5629512326840.32, + }, + "event_type": Object { + "tracking_type": "SignUp", + }, + "user": Object { + "aaid": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", + "data_processing_options": Object { + "country": "LT", + "modes": Array [ + "LDU", + ], + "region": "pB*l&&$y3eW4B", + }, + "email": "dc4e7023fdf4f0acfd7dd43c5379581ab280b6f189d9423ea35257a6f6f55b01", + "external_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", + "ip_address": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", + "phone_number": "73758501a37e57515d77d2b2e323e687abf03ba299a87f91f53cfad50665a2f3", + "screen_dimensions": Object { + "height": 562951232684032, + "width": 562951232684032, + }, + "user_agent": "pB*l&&$y3eW4B", + "uuid": "pB*l&&$y3eW4B", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: signUp action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "pB*l&&$y3eW4B", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", + }, + "event_type": Object { + "tracking_type": "SignUp", + }, + }, + ], + "partner": "SEGMENT", } `; @@ -115,7 +575,6 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, } `; @@ -134,6 +593,71 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: viewContent action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "OaH[Fid(DjO7DB2SWZ", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", + "currency": "TWD", + "item_count": 6698367690211328, + "products": Array [ + Object { + "category": "OaH[Fid(DjO7DB2SWZ", + "id": "OaH[Fid(DjO7DB2SWZ", + "name": "OaH[Fid(DjO7DB2SWZ", + }, + ], + "value_decimal": 66983676902113.28, + }, + "event_type": Object { + "tracking_type": "ViewContent", + }, + "user": Object { + "aaid": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", + "data_processing_options": Object { + "country": "TH", + "modes": Array [ + "LDU", + ], + "region": "OaH[Fid(DjO7DB2SWZ", + }, + "email": "de3399b808c0d5ef77de7a8ebe448d52cfc589255d422886ed5527203c0b98b9", + "external_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", + "ip_address": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", + "phone_number": "c37ec75a5e5691e5885e28fa159fd5d118517254b4941e41f8d14870406702e3", + "screen_dimensions": Object { + "height": 6698367690211328, + "width": 6698367690211328, + }, + "user_agent": "OaH[Fid(DjO7DB2SWZ", + "uuid": "OaH[Fid(DjO7DB2SWZ", + }, + }, + ], + "partner": "SEGMENT", +} +`; + +exports[`Testing snapshot for actions-reddit-conversions-api destination: viewContent action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "click_id": "OaH[Fid(DjO7DB2SWZ", + "event_at": "2021-02-01T00:00:00.000Z", + "event_metadata": Object { + "conversion_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", + }, + "event_type": Object { + "tracking_type": "ViewContent", + }, + }, + ], + "partner": "SEGMENT", } `; 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..dff32403ca0 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,13 +2,13 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' +import { FLAGON_NAME } from '../utils' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' const settings: Settings = { ad_account_id: 'ad_account_id_1', - conversion_token: 'conversion_token_1', - test_mode: false + conversion_token: 'conversion_token_1' } describe('Reddit Conversions Api', () => { @@ -48,7 +48,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - custom_event_name: 'Some Custom Event Name' + custom_event_name: 'Some Custom Event Name', + api_version: 'v2' } }) @@ -91,8 +92,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -131,7 +131,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - custom_event_name: 'Some Custom Event Name' + custom_event_name: 'Some Custom Event Name', + api_version: 'v2' } }) @@ -174,8 +175,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) }) @@ -217,7 +217,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Purchase' + tracking_type: 'Purchase', + api_version: 'v2' } }) @@ -259,8 +260,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -300,7 +300,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -341,8 +342,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -379,7 +379,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -419,8 +420,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -456,7 +456,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -494,8 +495,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -535,7 +535,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -576,8 +577,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -615,7 +615,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -656,8 +657,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -697,7 +697,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -738,8 +739,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -779,7 +779,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -820,8 +821,7 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) @@ -861,7 +861,8 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead' + tracking_type: 'Lead', + api_version: 'v2' } }) @@ -902,9 +903,163 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT', - test_mode: false + partner: 'SEGMENT' }) }) }) + + describe('v3 (canary)', () => { + it('should send a Purchase Standard event to v3 when api_version is v3 and action_source is set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { + click_id: 'click_id_1', + currency: 'USD', + quantity: 10, + revenue: 100, + uuid: 'uuid_1', + products: [{ product_id: 'product_id_1', category: 'category_1', name: 'name_1', quantity: 2, price: 25 }], + email: 'test@test.com' + }, + context: { + userAgent: 'test-user-agent', + ip: '111.111.111.111', + page: { url: 'https://example.com/checkout' } + } + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + features: { [FLAGON_NAME]: true }, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: 'WEBSITE', + test_id: 'test-123' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: { + partner: 'SEGMENT', + test_id: 'test-123', + events: [ + { + action_source: 'WEBSITE', + event_source_url: 'https://example.com/checkout', + click_id: 'click_id_1', + event_at: 1704721970212, + type: { + tracking_type: 'PURCHASE' + }, + event_metadata: { + currency: 'USD', + item_count: 10, + value: 100, + products: [ + { + category: 'category_1', + id: 'product_id_1', + name: 'name_1', + quantity: 2, + item_price: 25 + } + ] + } + } + ] + } + }) + }) + + it('should route a Custom event to v3 with UPPER_SNAKE_CASE tracking_type', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Some Custom Event Name', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: {} + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('customEvent', { + event, + settings, + useDefaultMappings: true, + features: { [FLAGON_NAME]: true }, + mapping: { + custom_event_name: 'Some Custom Event Name', + api_version: 'v3', + action_source: 'APP' + } + }) + + expect(responses.length).toBe(1) + const body = responses[0].options.json as { data: { events: Array<{ type: { tracking_type: string } }> } } + expect(body.data.events[0].type.tracking_type).toBe('CUSTOM') + }) + + it('should stay on v2 when the flag is on but api_version is not set (existing customers)', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + const responses = await testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + features: { [FLAGON_NAME]: true }, + // See the comment in the "dedicated event actions" describe block below: useDefaultMappings + // would otherwise merge in api_version's own default ('v3'), which isn't what an existing, + // pre-this-field customer's stored mapping looks like. + mapping: { + tracking_type: 'Purchase', + api_version: 'v2' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should reject the mapping when api_version is v3 but action_source is not set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + await expect( + testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + features: { [FLAGON_NAME]: true }, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3' + } + }) + ).rejects.toThrow() + }) + }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap index a1d96e2f9be..29ca8078e93 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -46,7 +46,6 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, } `; @@ -66,6 +65,5 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, } `; 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..3f3cdf0ff41 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,20 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed. + */ + api_version?: string + /** + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, 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..14ee3d5217a 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 @@ -1,7 +1,9 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' +import { resolveVersion } from '../utils' +import { sendV2 } from '../utils-v2' +import { sendV3 } from '../utils-v3' import { event_at, custom_event_name, @@ -11,7 +13,11 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + api_version, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,13 +32,24 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + api_version, + 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 resolveVersion(payload.api_version, features) === 'v3' + ? sendV3(request, settings, [payload]) + : sendV2(request, settings, [payload]) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') + const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') + const requests = [] + if (v2Payloads.length) requests.push(sendV2(request, settings, v2Payloads)) + if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) + return Promise.all(requests) } } 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..fc8a6ca6094 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -36,6 +36,58 @@ export const tracking_type: InputField = { ] } +export const api_version: InputField = { + label: 'API Version', + description: + 'The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed.', + type: 'string', + required: false, + default: 'v3', + choices: [ + { label: 'Latest (V3)', value: 'v3' }, + { label: 'Legacy (V2)', value: 'v2' } + ] +} + +const API_VERSION_IS_V3 = { + match: 'all' as const, + conditions: [{ fieldKey: 'api_version', operator: 'is' as const, value: 'v3' }] +} + +export const action_source: InputField = { + label: 'Action Source', + description: + 'The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3.', + type: 'string', + required: API_VERSION_IS_V3, + depends_on: API_VERSION_IS_V3, + 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, + depends_on: API_VERSION_IS_V3, + 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, + depends_on: API_VERSION_IS_V3 +} + export const click_id: InputField = { label: 'Click ID', description: 'The Reddit-generated id associated with a single ad click.', @@ -292,6 +344,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 +364,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/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts index 39e0e7bd5fd..15f94377ba3 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts @@ -9,8 +9,4 @@ export interface Settings { * The conversion token for your Reddit account. This can be found by following the steps mentioned [here](https://business.reddithelp.com/helpcenter/s/article/conversion-access-token). */ conversion_token: string - /** - * Indicates if events should be treated as test events by Reddit. - */ - test_mode?: boolean } 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..7068e3ab820 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -1,4 +1,4 @@ -import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import type { RedditConversionsTestAuthenticationError } from './types' import standardEvent from './standardEvent' @@ -25,13 +25,6 @@ const destination: DestinationDefinition = { 'The conversion token for your Reddit account. This can be found by following the steps mentioned [here](https://business.reddithelp.com/helpcenter/s/article/conversion-access-token).', type: 'password', required: true - }, - test_mode: { - label: 'Test Mode', - description: 'Indicates if events should be treated as test events by Reddit.', - type: 'boolean', - required: false, - default: false } }, testAuthentication: async (request, { settings }) => { @@ -93,107 +86,6 @@ const destination: DestinationDefinition = { partnerAction: 'redditPlugin', mapping: {}, type: 'automatic' - }, - { - name: 'Page Visit', - subscribe: 'type = "page"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'PageVisit', - event_metadata: {} - }, - type: 'automatic' - }, - { - name: 'View Content', - subscribe: 'type = "track" and event = "Product Viewed"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'ViewContent', - event_metadata: {} - }, - type: 'automatic' - }, - { - name: 'Search', - subscribe: 'type = "track" and event = "Products Searched"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'Search', - event_metadata: {} - }, - type: 'automatic' - }, - { - name: 'Add to Cart', - subscribe: 'type = "track" and event = "Product Added"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'AddToCart', - event_metadata: { - currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } - } - }, - type: 'automatic' - }, - { - name: 'Add to Wishlist', - subscribe: 'type = "track" and event = "Product Added to Wishlist"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'AddToWishlist', - event_metadata: { - currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } - } - }, - type: 'automatic' - }, - { - name: 'Purchase', - subscribe: 'type = "track" and event = "Order Completed"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'Purchase' - }, - type: 'automatic' - }, - { - name: 'Lead', - subscribe: 'type = "track" and event = "Lead Generated"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'Lead', - event_metadata: { - currency: { '@path': '$.properties.currency' }, - value_decimal: { '@path': '$.properties.price' } - } - }, - type: 'automatic' - }, - { - name: 'Sign Up', - subscribe: 'type = "track" and event = "Signed Up"', - partnerAction: 'standardEvent', - mapping: { - ...defaultValues(standardEvent.fields), - tracking_type: 'SignUp', - event_metadata: { - currency: { '@path': '$.properties.currency' }, - value_decimal: { '@path': '$.properties.price' } - } - }, - type: 'automatic' } ], diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 8dfcd05f7c1..15a993258eb 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -45,7 +45,6 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, } `; @@ -64,6 +63,5 @@ Object { }, ], "partner": "SEGMENT", - "test_mode": false, } `; 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..131322a454e 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,20 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed. + */ + api_version?: string + /** + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, 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..d4218ceeba7 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 @@ -1,7 +1,9 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' +import { resolveVersion } from '../utils' +import { sendV2 } from '../utils-v2' +import { sendV3 } from '../utils-v3' import { event_at, tracking_type, @@ -11,7 +13,11 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + api_version, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,13 +32,24 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + api_version, + 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 resolveVersion(payload.api_version, features) === 'v3' + ? sendV3(request, settings, [payload]) + : sendV2(request, settings, [payload]) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') + const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') + const requests = [] + if (v2Payloads.length) requests.push(sendV2(request, settings, v2Payloads)) + if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) + return Promise.all(requests) } } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts new file mode 100644 index 00000000000..29a413cb75e --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts @@ -0,0 +1,51 @@ +export interface Product { + category?: string + id?: string + name?: string +} + +export interface EventMetadata { + currency?: string + item_count?: number + value_decimal?: number + conversion_id?: string + products?: Array +} + +export interface DatapProcessingOptions { + country?: string + modes?: string[] + region?: string +} + +export interface User { + idfa?: string + aaid?: string + email?: string + external_id?: string + ip_address?: string + user_agent?: string + uuid?: string + data_processing_options?: DatapProcessingOptions + screen_dimensions?: { + height?: number + width?: number + } + phone_number?: string +} + +export interface StandardEventPayloadItem { + event_at: string + event_type: { + tracking_type: string + custom_event_name?: string + } + click_id?: string + event_metadata?: EventMetadata + user?: User +} + +export interface StandardEventPayload { + events: StandardEventPayloadItem[] + partner: 'SEGMENT' +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts new file mode 100644 index 00000000000..21c97820de7 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts @@ -0,0 +1,58 @@ +export interface V3Product { + category?: string + id?: string + name?: string + quantity?: number + item_price?: number +} + +export interface V3Metadata { + currency?: string + item_count?: number + value?: number + conversion_id?: string + products?: Array +} + +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 V3EventItem { + event_at: number + action_source?: string + event_source_url?: string + click_id?: string + type: { + tracking_type: string + custom_event_name?: string + } + event_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/types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts index e9a6ca9f5f2..cf3a92b031f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -8,56 +8,3 @@ export class RedditConversionsTestAuthenticationError extends HTTPError { } } } - -export interface Product { - category?: string - id?: string - name?: string -} - -export interface EventMetadata { - currency?: string - item_count?: number - value_decimal?: number - conversion_id?: string - products?: Array -} - -export interface DatapProcessingOptions { - country?: string - modes?: string[] - region?: string -} - -export interface User { - idfa?: string - aaid?: string - email?: string - external_id?: string - ip_address?: string - user_agent?: string - uuid?: string - data_processing_options?: DatapProcessingOptions - screen_dimensions?: { - height?: number - width?: number - } - phone_number?: string -} - -export interface StandardEventPayloadItem { - event_at: string - event_type: { - tracking_type: string - custom_event_name?: string - } - click_id?: string - event_metadata?: EventMetadata - user?: User -} - -export interface StandardEventPayload { - events: StandardEventPayloadItem[] - test_mode?: boolean - partner: 'SEGMENT' -} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts new file mode 100644 index 00000000000..2a966f404cb --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts @@ -0,0 +1,196 @@ +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-v2' +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 sendV2(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { + const data = createRedditPayloadV2(payload) + 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 createRedditPayloadV2(payloads: StandardEvent[] | CustomEvent[]): 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, + 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 +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts new file mode 100644 index 00000000000..6ec5b932d0d --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts @@ -0,0 +1,234 @@ +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 { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types-v3' +import { processHashing } from '../../lib/hashing-utils' +import { REDDIT_CONVERSIONS_CANARY_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'] + +const V3_URL = (adAccountId: string) => + `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_CANARY_API_VERSION}/pixels/${adAccountId}/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] as StandardEvent | CustomEvent)?.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) { + // The `action_source` field is conditionally required (only when `api_version` is `'v3'`), + // so schema validation should already guarantee this is present by the time we get here. + // This is a defensive backstop, not the primary enforcement mechanism. + throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') + } + + 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 + }, + event_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 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): 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), + // The Segment-facing field is still named `value_decimal` (unchanged from v2, so existing + // mappings keep working) - only the wire-level key sent to Reddit v3 renames to `value`. + value: 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 +): V3DataProcessingOptions | 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 +): 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) + } +} + +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 +} 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..ecf4be56d8c 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,27 @@ -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 { 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 type { Features } from '@segment/actions-core' + +/** FLAGON_NAME + * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. + * https://flagon.segment.com/families/centrifuge-destinations/gates/reddit-conversions-api-canary-version + */ +export const FLAGON_NAME = 'reddit-conversions-api-canary-version' + +export function isCanary(features?: Features): boolean { + return !!(features && features[FLAGON_NAME]) +} + +/** resolveVersion + * Decides which Reddit Conversions API version a given event should use. + * + * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, + * regardless of the customer's `api_version` field selection. If the flag is on, the customer's + * per-action `api_version` field decides: existing customers who configured this action before + * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, + * until they explicitly opt into `'v3'`. + */ +export function resolveVersion(apiVersion: string | undefined, features?: Features): 'v2' | 'v3' { + if (!isCanary(features)) { + return 'v2' + } + return apiVersion === 'v3' ? 'v3' : 'v2' } 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..1517e6c07a0 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 (legacy). * 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 (latest), behind the `reddit-conversions-api-canary-version` + * Flagon flag and the per-action `api_version` field. + * API reference: https://ads-api.reddit.com/docs/v3/api/post-conversion-events + * Migration guide: https://ads-api.reddit.com/docs/v3/guides/programs/capi/migration + */ +export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3' From c92eaa38aa59526e15e44aa994ab095661c37bc1 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 08:01:05 +0100 Subject: [PATCH 02/20] refactor(reddit-conversions-api): drop the v2 file split, restore utils.ts/types.ts Deletes types-v2.ts and utils-v2.ts, and restores utils.ts/types.ts to their original main-branch content verbatim. v2 stays completely untouched; v3 is purely additive in types-v3.ts/utils-v3.ts, which now also owns the FLAGON_NAME/isCanary/resolveVersion routing helpers. standardEvent/customEvent call the original send() for the v2 path and sendV3() for v3, per resolveVersion(payload.api_version, features). Known follow-up: utils.ts still references settings.test_mode, which no longer exists on Settings (removed earlier). Left as-is per instruction not to worry about lint/type checks for this push. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/index.test.ts | 2 +- .../customEvent/index.ts | 9 +- .../standardEvent/index.ts | 9 +- .../reddit-conversions-api/types-v2.ts | 51 ---- .../reddit-conversions-api/types.ts | 53 +++++ .../reddit-conversions-api/utils-v2.ts | 196 ---------------- .../reddit-conversions-api/utils-v3.ts | 28 ++- .../reddit-conversions-api/utils.ts | 222 ++++++++++++++++-- 8 files changed, 285 insertions(+), 285 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts delete mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts 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 dff32403ca0..cccd949a023 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,7 +2,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' -import { FLAGON_NAME } from '../utils' +import { FLAGON_NAME } from '../utils-v3' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' 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 14ee3d5217a..84d6477ebd0 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 @@ -1,9 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { resolveVersion } from '../utils' -import { sendV2 } from '../utils-v2' -import { sendV3 } from '../utils-v3' +import { send } from '../utils' +import { resolveVersion, sendV3 } from '../utils-v3' import { event_at, custom_event_name, @@ -41,13 +40,13 @@ const action: ActionDefinition = { perform: async (request, { settings, payload, features }) => { return resolveVersion(payload.api_version, features) === 'v3' ? sendV3(request, settings, [payload]) - : sendV2(request, settings, [payload]) + : send(request, settings, [payload]) }, performBatch: async (request, { settings, payload, features }) => { const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') const requests = [] - if (v2Payloads.length) requests.push(sendV2(request, settings, v2Payloads)) + if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) return Promise.all(requests) } 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 d4218ceeba7..c8cd784767e 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 @@ -1,9 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { resolveVersion } from '../utils' -import { sendV2 } from '../utils-v2' -import { sendV3 } from '../utils-v3' +import { send } from '../utils' +import { resolveVersion, sendV3 } from '../utils-v3' import { event_at, tracking_type, @@ -41,13 +40,13 @@ const action: ActionDefinition = { perform: async (request, { settings, payload, features }) => { return resolveVersion(payload.api_version, features) === 'v3' ? sendV3(request, settings, [payload]) - : sendV2(request, settings, [payload]) + : send(request, settings, [payload]) }, performBatch: async (request, { settings, payload, features }) => { const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') const requests = [] - if (v2Payloads.length) requests.push(sendV2(request, settings, v2Payloads)) + if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) return Promise.all(requests) } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts deleted file mode 100644 index 29a413cb75e..00000000000 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types-v2.ts +++ /dev/null @@ -1,51 +0,0 @@ -export interface Product { - category?: string - id?: string - name?: string -} - -export interface EventMetadata { - currency?: string - item_count?: number - value_decimal?: number - conversion_id?: string - products?: Array -} - -export interface DatapProcessingOptions { - country?: string - modes?: string[] - region?: string -} - -export interface User { - idfa?: string - aaid?: string - email?: string - external_id?: string - ip_address?: string - user_agent?: string - uuid?: string - data_processing_options?: DatapProcessingOptions - screen_dimensions?: { - height?: number - width?: number - } - phone_number?: string -} - -export interface StandardEventPayloadItem { - event_at: string - event_type: { - tracking_type: string - custom_event_name?: string - } - click_id?: string - event_metadata?: EventMetadata - user?: User -} - -export interface StandardEventPayload { - events: StandardEventPayloadItem[] - partner: 'SEGMENT' -} 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 cf3a92b031f..e9a6ca9f5f2 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -8,3 +8,56 @@ export class RedditConversionsTestAuthenticationError extends HTTPError { } } } + +export interface Product { + category?: string + id?: string + name?: string +} + +export interface EventMetadata { + currency?: string + item_count?: number + value_decimal?: number + conversion_id?: string + products?: Array +} + +export interface DatapProcessingOptions { + country?: string + modes?: string[] + region?: string +} + +export interface User { + idfa?: string + aaid?: string + email?: string + external_id?: string + ip_address?: string + user_agent?: string + uuid?: string + data_processing_options?: DatapProcessingOptions + screen_dimensions?: { + height?: number + width?: number + } + phone_number?: string +} + +export interface StandardEventPayloadItem { + event_at: string + event_type: { + tracking_type: string + custom_event_name?: string + } + click_id?: string + event_metadata?: EventMetadata + user?: User +} + +export interface StandardEventPayload { + events: StandardEventPayloadItem[] + test_mode?: boolean + partner: 'SEGMENT' +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts deleted file mode 100644 index 2a966f404cb..00000000000 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v2.ts +++ /dev/null @@ -1,196 +0,0 @@ -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-v2' -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 sendV2(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { - const data = createRedditPayloadV2(payload) - 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 createRedditPayloadV2(payloads: StandardEvent[] | CustomEvent[]): 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, - 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 -} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts index 6ec5b932d0d..7da0878061f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts @@ -1,4 +1,4 @@ -import type { RequestClient } from '@segment/actions-core' +import type { Features, 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' @@ -7,6 +7,32 @@ import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessing import { processHashing } from '../../lib/hashing-utils' import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +/** FLAGON_NAME + * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. + * https://flagon.segment.com/families/centrifuge-destinations/gates/reddit-conversions-api-canary-version + */ +export const FLAGON_NAME = 'reddit-conversions-api-canary-version' + +export function isCanary(features?: Features): boolean { + return !!(features && features[FLAGON_NAME]) +} + +/** resolveVersion + * Decides which Reddit Conversions API version a given event should use. + * + * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, + * regardless of the customer's `api_version` field selection. If the flag is on, the customer's + * per-action `api_version` field decides: existing customers who configured this action before + * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, + * until they explicitly opt into `'v3'`. + */ +export function resolveVersion(apiVersion: string | undefined, features?: Features): 'v2' | 'v3' { + if (!isCanary(features)) { + return 'v2' + } + return apiVersion === 'v3' ? 'v3' : 'v2' +} + type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] 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 ecf4be56d8c..4a6e6f45ad1 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -1,27 +1,197 @@ -import type { Features } from '@segment/actions-core' - -/** FLAGON_NAME - * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. - * https://flagon.segment.com/families/centrifuge-destinations/gates/reddit-conversions-api-canary-version - */ -export const FLAGON_NAME = 'reddit-conversions-api-canary-version' - -export function isCanary(features?: Features): boolean { - return !!(features && features[FLAGON_NAME]) -} - -/** resolveVersion - * Decides which Reddit Conversions API version a given event should use. - * - * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, - * regardless of the customer's `api_version` field selection. If the flag is on, the customer's - * per-action `api_version` field decides: existing customers who configured this action before - * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, - * until they explicitly opt into `'v3'`. - */ -export function resolveVersion(apiVersion: string | undefined, features?: Features): 'v2' | 'v3' { - if (!isCanary(features)) { - return 'v2' - } - return apiVersion === 'v3' ? 'v3' : 'v2' +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 { 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 } From 7baa5e42e1fb8377b5af3ca7e780bacba91f70a8 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 08:16:40 +0100 Subject: [PATCH 03/20] Move types-v3/utils-v3 into v3 folder, restore test_mode setting - Move types-v3.ts and utils-v3.ts into a new v3/ subfolder, fixing their relative imports and all call sites - Re-add the test_mode Settings field (removed earlier, but still referenced by v2 utils.ts) and regenerate generated-types.ts + metadata.json - Update test_mode's description to clarify it's V2-only (deprecated) and point to the per-action Test ID field for V3 test events Co-Authored-By: Claude Sonnet 5 --- .../reddit-conversions-api/__tests__/index.test.ts | 2 +- .../reddit-conversions-api/customEvent/index.ts | 2 +- .../reddit-conversions-api/generated-types.ts | 4 ++++ .../src/destinations/reddit-conversions-api/index.ts | 8 ++++++++ .../destinations/reddit-conversions-api/metadata.json | 4 ++-- .../reddit-conversions-api/standardEvent/index.ts | 2 +- .../reddit-conversions-api/{ => v3}/types-v3.ts | 0 .../reddit-conversions-api/{ => v3}/utils-v3.ts | 10 +++++----- 8 files changed, 22 insertions(+), 10 deletions(-) rename packages/destination-actions/src/destinations/reddit-conversions-api/{ => v3}/types-v3.ts (100%) rename packages/destination-actions/src/destinations/reddit-conversions-api/{ => v3}/utils-v3.ts (96%) 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 cccd949a023..6eec9b3c807 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,7 +2,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' -import { FLAGON_NAME } from '../utils-v3' +import { FLAGON_NAME } from '../v3/utils-v3' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' 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 84d6477ebd0..bb4f0a1014f 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 @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from '../utils' -import { resolveVersion, sendV3 } from '../utils-v3' +import { resolveVersion, sendV3 } from '../v3/utils-v3' import { event_at, custom_event_name, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts index 15f94377ba3..3fe282e6ed5 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts @@ -9,4 +9,8 @@ export interface Settings { * The conversion token for your Reddit account. This can be found by following the steps mentioned [here](https://business.reddithelp.com/helpcenter/s/article/conversion-access-token). */ conversion_token: string + /** + * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead. + */ + test_mode?: boolean } 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 7068e3ab820..84099cba546 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -25,6 +25,14 @@ const destination: DestinationDefinition = { 'The conversion token for your Reddit account. This can be found by following the steps mentioned [here](https://business.reddithelp.com/helpcenter/s/article/conversion-access-token).', type: 'password', required: true + }, + test_mode: { + label: '[Deprecated] Test Mode', + description: + 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.', + type: 'boolean', + required: false, + default: false } }, testAuthentication: async (request, { settings }) => { 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..8afeab57cb1 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -27,8 +27,8 @@ "depends_on": null }, "test_mode": { - "label": "Test Mode", - "description": "Indicates if events should be treated as test events by Reddit.", + "label": "[Deprecated] Test Mode", + "description": "Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.", "type": "boolean", "required": false, "multiple": false, 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 c8cd784767e..12089f88bc1 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 @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from '../utils' -import { resolveVersion, sendV3 } from '../utils-v3' +import { resolveVersion, sendV3 } from '../v3/utils-v3' import { event_at, tracking_type, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts similarity index 100% rename from packages/destination-actions/src/destinations/reddit-conversions-api/types-v3.ts rename to packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts similarity index 96% rename from packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts rename to packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 7da0878061f..9a48e29b34d 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -1,11 +1,11 @@ import type { Features, 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 type { Settings } from '../generated-types' +import type { Payload as StandardEvent } from '../standardEvent/generated-types' +import type { Payload as CustomEvent } from '../customEvent/generated-types' import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types-v3' -import { processHashing } from '../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +import { processHashing } from '../../../lib/hashing-utils' +import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from '../versioning-info' /** FLAGON_NAME * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. From 489bc417cc70627c32dc3e37109d0a2d7d5d6c85 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 08:25:37 +0100 Subject: [PATCH 04/20] Centralize v2/v3 version identifiers into versioning-info.ts - Replace raw 'v2'/'v3' string literals with named constants (LEGACY_API_VERSION, LATEST_API_VERSION) exported from versioning-info.ts, used consistently across fields.ts, resolveVersion(), and both action perform/performBatch handlers - LEGACY_API_VERSION reuses the existing v2.0 URL-segment constant rather than introducing a separate 'v2' identifier Co-Authored-By: Claude Sonnet 5 --- .../reddit-conversions-api/customEvent/index.ts | 7 ++++--- .../reddit-conversions-api/fields.ts | 9 +++++---- .../destinations/reddit-conversions-api/index.ts | 4 ++-- .../standardEvent/index.ts | 7 ++++--- .../destinations/reddit-conversions-api/utils.ts | 4 ++-- .../reddit-conversions-api/v3/utils-v3.ts | 10 +++++----- .../reddit-conversions-api/versioning-info.ts | 16 +++------------- 7 files changed, 25 insertions(+), 32 deletions(-) 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 bb4f0a1014f..76e2b0880d2 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 @@ -3,6 +3,7 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from '../utils' import { resolveVersion, sendV3 } from '../v3/utils-v3' +import { LEGACY_API_VERSION, LATEST_API_VERSION } from '../versioning-info' import { event_at, custom_event_name, @@ -38,13 +39,13 @@ const action: ActionDefinition = { test_id }, perform: async (request, { settings, payload, features }) => { - return resolveVersion(payload.api_version, features) === 'v3' + return resolveVersion(payload.api_version, features) === LATEST_API_VERSION ? sendV3(request, settings, [payload]) : send(request, settings, [payload]) }, performBatch: async (request, { settings, payload, features }) => { - const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') - const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') + const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) + const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) const requests = [] if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) 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 fc8a6ca6094..c98a94fd82e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -1,4 +1,5 @@ import { InputField } from '@segment/actions-core/destination-kit/types' +import { LEGACY_API_VERSION, LATEST_API_VERSION } from './versioning-info' export const event_at: InputField = { label: 'Event At', @@ -42,16 +43,16 @@ export const api_version: InputField = { 'The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed.', type: 'string', required: false, - default: 'v3', + default: LATEST_API_VERSION, choices: [ - { label: 'Latest (V3)', value: 'v3' }, - { label: 'Legacy (V2)', value: 'v2' } + { label: 'Latest (V3)', value: LATEST_API_VERSION }, + { label: 'Legacy (V2)', value: LEGACY_API_VERSION } ] } const API_VERSION_IS_V3 = { match: 'all' as const, - conditions: [{ fieldKey: 'api_version', operator: 'is' as const, value: 'v3' }] + conditions: [{ fieldKey: 'api_version', operator: 'is' as const, value: LATEST_API_VERSION }] } export const action_source: InputField = { 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 84099cba546..24a5554db7d 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -3,7 +3,7 @@ import type { Settings } from './generated-types' import type { RedditConversionsTestAuthenticationError } from './types' import standardEvent from './standardEvent' import customEvent from './customEvent' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { LEGACY_API_VERSION } from './versioning-info' const destination: DestinationDefinition = { name: 'Reddit Conversions API', @@ -38,7 +38,7 @@ const destination: DestinationDefinition = { testAuthentication: async (request, { settings }) => { try { return await request( - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${settings.ad_account_id}`, + `https://ads-api.reddit.com/api/${LEGACY_API_VERSION}/conversions/events/${settings.ad_account_id}`, { method: 'POST', headers: { 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 12089f88bc1..b3c2eac81ad 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 @@ -3,6 +3,7 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from '../utils' import { resolveVersion, sendV3 } from '../v3/utils-v3' +import { LEGACY_API_VERSION, LATEST_API_VERSION } from '../versioning-info' import { event_at, tracking_type, @@ -38,13 +39,13 @@ const action: ActionDefinition = { test_id }, perform: async (request, { settings, payload, features }) => { - return resolveVersion(payload.api_version, features) === 'v3' + return resolveVersion(payload.api_version, features) === LATEST_API_VERSION ? sendV3(request, settings, [payload]) : send(request, settings, [payload]) }, performBatch: async (request, { settings, payload, features }) => { - const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v2') - const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === 'v3') + const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) + const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) const requests = [] if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) 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..f710e6d84a7 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -11,7 +11,7 @@ import { DatapProcessingOptions } from './types' import { processHashing } from '../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { LEGACY_API_VERSION } from './versioning-info' type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] @@ -23,7 +23,7 @@ type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['sc 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}`, + `https://ads-api.reddit.com/api/${LEGACY_API_VERSION}/conversions/events/${settings.ad_account_id}`, { method: 'POST', headers: { Authorization: `Bearer ${settings.conversion_token}` }, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 9a48e29b34d..484becad071 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -5,7 +5,7 @@ import type { Payload as StandardEvent } from '../standardEvent/generated-types' import type { Payload as CustomEvent } from '../customEvent/generated-types' import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types-v3' import { processHashing } from '../../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from '../versioning-info' +import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from '../versioning-info' /** FLAGON_NAME * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. @@ -26,11 +26,11 @@ export function isCanary(features?: Features): boolean { * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, * until they explicitly opt into `'v3'`. */ -export function resolveVersion(apiVersion: string | undefined, features?: Features): 'v2' | 'v3' { +export function resolveVersion(apiVersion: string | undefined, features?: Features): ApiVersion { if (!isCanary(features)) { - return 'v2' + return LEGACY_API_VERSION } - return apiVersion === 'v3' ? 'v3' : 'v2' + return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION } type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] @@ -41,7 +41,7 @@ type UserType = StandardEvent['user'] | CustomEvent['user'] type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] const V3_URL = (adAccountId: string) => - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_CANARY_API_VERSION}/pixels/${adAccountId}/conversion_events` + `https://ads-api.reddit.com/api/${LATEST_API_VERSION}/pixels/${adAccountId}/conversion_events` // v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. const TRACKING_TYPE_V3: Record = { 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 1517e6c07a0..d5572f8fbb3 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,13 +1,3 @@ -/** REDDIT_CONVERSIONS_API_VERSION - * Reddit conversions API version (legacy). - * 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 (latest), behind the `reddit-conversions-api-canary-version` - * Flagon flag and the per-action `api_version` field. - * API reference: https://ads-api.reddit.com/docs/v3/api/post-conversion-events - * Migration guide: https://ads-api.reddit.com/docs/v3/guides/programs/capi/migration - */ -export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3' +export const LEGACY_API_VERSION = 'v2.0' +export const LATEST_API_VERSION = 'v3' +export type ApiVersion = typeof LEGACY_API_VERSION | typeof LATEST_API_VERSION \ No newline at end of file From 34257f769096a051b8c4b24da30b50f6ea99acbb Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 09:11:08 +0100 Subject: [PATCH 05/20] Extract standardEvent action definition into a reusable factory - Add action.ts exporting standardEventAction(trackingType, title, description, defaultSubscription?) - builds the shared fields/ perform/performBatch logic for the standardEvent action family. When trackingType is set, tracking_type is hardcoded and hidden from the UI (for future preset-replacement actions); when undefined, tracking_type stays user-selectable (current standardEvent behavior) - standardEvent/index.ts is now a one-line call into the factory - Move isCanary/resolveVersion from v3/utils-v3.ts into action.ts (FLAGON_NAME stays in v3/utils-v3.ts); customEvent/index.ts now imports resolveVersion from ../action instead Co-Authored-By: Claude Sonnet 5 --- .../reddit-conversions-api/action.ts | 86 +++++++++++++++++++ .../customEvent/index.ts | 3 +- .../standardEvent/index.ts | 60 ++----------- .../reddit-conversions-api/v3/utils-v3.ts | 24 +----- 4 files changed, 96 insertions(+), 77 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/action.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts new file mode 100644 index 00000000000..fddf2d8d81d --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts @@ -0,0 +1,86 @@ +import type { ActionDefinition, Features } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import { send } from './utils' +import { sendV3, FLAGON_NAME } from './v3/utils-v3' +import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from './versioning-info' +import { + event_at, + tracking_type, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + api_version, + action_source, + event_source_url, + test_id +} from './fields' + +export function isCanary(features?: Features): boolean { + return features?.[FLAGON_NAME] ?? false +} + +/** resolveVersion + * Decides which Reddit Conversions API version a given event should use. + * + * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, + * regardless of the customer's `api_version` field selection. If the flag is on, the customer's + * per-action `api_version` field decides: existing customers who configured this action before + * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, + * until they explicitly opt into `'v3'`. + */ +export function resolveVersion(apiVersion: string | undefined, features?: Features): ApiVersion { + if (!isCanary(features)) { + return LEGACY_API_VERSION + } + return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION +} + +export function standardEventAction( + // When set, tracking_type is hardcoded to this value and hidden from the UI, replicating + // the old per-event-type presets. When undefined, tracking_type stays a user-selectable field. + trackingType: string | undefined, + title: string, + description: string, + defaultSubscription?: string +): ActionDefinition { + return { + title, + description, + defaultSubscription, + fields: { + event_at, + ...(trackingType ? {} : { tracking_type }), + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + api_version, + action_source, + event_source_url, + test_id + }, + perform: async (request, { settings, payload, features }) => { + const resolvedPayload = trackingType ? { ...payload, tracking_type: trackingType } : payload + return resolveVersion(payload.api_version, features) === LATEST_API_VERSION + ? sendV3(request, settings, [resolvedPayload]) + : send(request, settings, [resolvedPayload]) + }, + performBatch: async (request, { settings, payload, features }) => { + const resolvedPayloads = trackingType ? payload.map((p) => ({ ...p, tracking_type: trackingType })) : payload + const v2Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) + const v3Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) + const requests = [] + if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) + if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) + return Promise.all(requests) + } + } +} 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 76e2b0880d2..a6c1ffdaceb 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 @@ -2,7 +2,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from '../utils' -import { resolveVersion, sendV3 } from '../v3/utils-v3' +import { sendV3 } from '../v3/utils-v3' +import { resolveVersion } from '../action' import { LEGACY_API_VERSION, LATEST_API_VERSION } from '../versioning-info' import { event_at, 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 b3c2eac81ad..f89eb350eb5 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 @@ -1,56 +1,8 @@ -import type { ActionDefinition } from '@segment/actions-core' -import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' -import { resolveVersion, sendV3 } from '../v3/utils-v3' -import { LEGACY_API_VERSION, LATEST_API_VERSION } from '../versioning-info' -import { - event_at, - tracking_type, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id, - api_version, - action_source, - event_source_url, - test_id -} from '../fields' +import { standardEventAction } from '../action' -const action: ActionDefinition = { - title: 'Send Standard Event', - description: 'Send a Standard Conversion Event to Reddit', - fields: { - event_at, - tracking_type, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id, - api_version, - action_source, - event_source_url, - test_id - }, - perform: async (request, { settings, payload, features }) => { - return resolveVersion(payload.api_version, features) === LATEST_API_VERSION - ? sendV3(request, settings, [payload]) - : send(request, settings, [payload]) - }, - performBatch: async (request, { settings, payload, features }) => { - const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) - const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) - const requests = [] - if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) - if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) - return Promise.all(requests) - } -} - -export default action +export default standardEventAction( + undefined, + 'Send Standard Event', + 'Send a Standard Conversion Event to Reddit' +) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 484becad071..26a84845a34 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -1,11 +1,11 @@ -import type { Features, RequestClient } from '@segment/actions-core' +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 { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types-v3' import { processHashing } from '../../../lib/hashing-utils' -import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from '../versioning-info' +import { LATEST_API_VERSION } from '../versioning-info' /** FLAGON_NAME * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. @@ -13,26 +13,6 @@ import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from '../versionin */ export const FLAGON_NAME = 'reddit-conversions-api-canary-version' -export function isCanary(features?: Features): boolean { - return !!(features && features[FLAGON_NAME]) -} - -/** resolveVersion - * Decides which Reddit Conversions API version a given event should use. - * - * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, - * regardless of the customer's `api_version` field selection. If the flag is on, the customer's - * per-action `api_version` field decides: existing customers who configured this action before - * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, - * until they explicitly opt into `'v3'`. - */ -export function resolveVersion(apiVersion: string | undefined, features?: Features): ApiVersion { - if (!isCanary(features)) { - return LEGACY_API_VERSION - } - return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION -} - type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] From 55fa1366af1508377f63740a12e292a1b020c1ca Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 09:24:58 +0100 Subject: [PATCH 06/20] Reframe V2 as standard/V3 as beta; restore V2-pinned presets - api_version field: default flips to LEGACY_API_VERSION (V2); choices/descriptions across fields.ts and test_mode reworded to frame V2 as the current standard and V3 as beta, not "legacy vs latest" - Restore the 8 tracking_type presets (Page Visit, View Content, Search, Add to Cart, Add to Wishlist, Purchase, Lead, Sign Up) removed earlier in this branch, matching main's mapping shape but with api_version explicitly pinned to LEGACY_API_VERSION so they always resolve to V2 regardless of the Flagon flag state - Regenerate generated-types.ts for root/standardEvent/customEvent to match the description wording changes Co-Authored-By: Claude Sonnet 5 --- .../customEvent/generated-types.ts | 12 +- .../reddit-conversions-api/fields.ts | 18 +-- .../reddit-conversions-api/generated-types.ts | 2 +- .../reddit-conversions-api/index.ts | 115 +++++++++++++++++- .../standardEvent/generated-types.ts | 12 +- 5 files changed, 134 insertions(+), 25 deletions(-) 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 3f3cdf0ff41..713899c4062 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 @@ -30,11 +30,11 @@ export interface Payload { */ name?: string /** - * The number of this product in the event. Only applies to Reddit Conversions API V3. + * The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta). */ quantity?: number /** - * The unit price of the product. Only applies to Reddit Conversions API V3. + * The unit price of the product. Only applies to Reddit Conversions API V3 (Beta). */ item_price?: number }[] @@ -127,19 +127,19 @@ export interface Payload { */ conversion_id?: string /** - * The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed. + * The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set. */ api_version?: string /** - * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3. + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta). */ 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. + * 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 (Beta). */ 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. + * 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 (Beta). */ test_id?: string } 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 c98a94fd82e..478a6772925 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -40,13 +40,13 @@ export const tracking_type: InputField = { export const api_version: InputField = { label: 'API Version', description: - 'The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed.', + 'The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set.', type: 'string', required: false, - default: LATEST_API_VERSION, + default: LEGACY_API_VERSION, choices: [ - { label: 'Latest (V3)', value: LATEST_API_VERSION }, - { label: 'Legacy (V2)', value: LEGACY_API_VERSION } + { label: 'V3 (Beta)', value: LATEST_API_VERSION }, + { label: 'V2', value: LEGACY_API_VERSION } ] } @@ -58,7 +58,7 @@ const API_VERSION_IS_V3 = { export const action_source: InputField = { label: 'Action Source', description: - 'The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3.', + 'The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta).', type: 'string', required: API_VERSION_IS_V3, depends_on: API_VERSION_IS_V3, @@ -73,7 +73,7 @@ export const action_source: InputField = { 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.', + '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 (Beta).', type: 'string', required: false, depends_on: API_VERSION_IS_V3, @@ -83,7 +83,7 @@ export const event_source_url: InputField = { 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.', + '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 (Beta).', type: 'string', required: false, depends_on: API_VERSION_IS_V3 @@ -348,13 +348,13 @@ export const products: InputField = { }, quantity: { label: 'Quantity', - description: 'The number of this product in the event. Only applies to Reddit Conversions API V3.', + description: 'The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta).', type: 'integer', required: false }, item_price: { label: 'Item Price', - description: 'The unit price of the product. Only applies to Reddit Conversions API V3.', + description: 'The unit price of the product. Only applies to Reddit Conversions API V3 (Beta).', type: 'number', required: false } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts index 3fe282e6ed5..4bc5e5b80cb 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts @@ -10,7 +10,7 @@ export interface Settings { */ conversion_token: string /** - * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead. + * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead. */ test_mode?: boolean } 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 24a5554db7d..af4529664d5 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -1,4 +1,4 @@ -import { DestinationDefinition } from '@segment/actions-core' +import { defaultValues, DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import type { RedditConversionsTestAuthenticationError } from './types' import standardEvent from './standardEvent' @@ -27,9 +27,9 @@ const destination: DestinationDefinition = { required: true }, test_mode: { - label: '[Deprecated] Test Mode', + label: 'Test Mode', description: - 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.', + 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.', type: 'boolean', required: false, default: false @@ -94,6 +94,115 @@ const destination: DestinationDefinition = { partnerAction: 'redditPlugin', mapping: {}, type: 'automatic' + }, + { + name: 'Page Visit', + subscribe: 'type = "page"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'PageVisit', + event_metadata: {} + }, + type: 'automatic' + }, + { + name: 'View Content', + subscribe: 'type = "track" and event = "Product Viewed"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'ViewContent', + event_metadata: {} + }, + type: 'automatic' + }, + { + name: 'Search', + subscribe: 'type = "track" and event = "Products Searched"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'Search', + event_metadata: {} + }, + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'type = "track" and event = "Product Added"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'AddToCart', + event_metadata: { + currency: { '@path': '$.properties.currency' }, + itemCount: { '@path': '$.properties.quantity' }, + value: { '@path': '$.properties.price' } + } + }, + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'type = "track" and event = "Product Added to Wishlist"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'AddToWishlist', + event_metadata: { + currency: { '@path': '$.properties.currency' }, + itemCount: { '@path': '$.properties.quantity' }, + value: { '@path': '$.properties.price' } + } + }, + type: 'automatic' + }, + { + name: 'Purchase', + subscribe: 'type = "track" and event = "Order Completed"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'Purchase' + }, + type: 'automatic' + }, + { + name: 'Lead', + subscribe: 'type = "track" and event = "Lead Generated"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'Lead', + event_metadata: { + currency: { '@path': '$.properties.currency' }, + value_decimal: { '@path': '$.properties.price' } + } + }, + type: 'automatic' + }, + { + name: 'Sign Up', + subscribe: 'type = "track" and event = "Signed Up"', + partnerAction: 'standardEvent', + mapping: { + ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, + tracking_type: 'SignUp', + event_metadata: { + currency: { '@path': '$.properties.currency' }, + value_decimal: { '@path': '$.properties.price' } + } + }, + type: 'automatic' } ], 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 131322a454e..aaaeb231f51 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 @@ -30,11 +30,11 @@ export interface Payload { */ name?: string /** - * The number of this product in the event. Only applies to Reddit Conversions API V3. + * The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta). */ quantity?: number /** - * The unit price of the product. Only applies to Reddit Conversions API V3. + * The unit price of the product. Only applies to Reddit Conversions API V3 (Beta). */ item_price?: number }[] @@ -127,19 +127,19 @@ export interface Payload { */ conversion_id?: string /** - * The version of the Reddit Conversions API to send this event to. "Latest (V3)" requires Action Source to be set. Accounts that configured this action before this setting existed keep sending on "Legacy (V2)" until this is explicitly changed. + * The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set. */ api_version?: string /** - * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3. + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta). */ 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. + * 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 (Beta). */ 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. + * 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 (Beta). */ test_id?: string } From b89918e579f196234abfa36b1746128f5b0e4556 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 09:48:47 +0100 Subject: [PATCH 07/20] Migrate customEvent onto action.ts; drop Flagon flag gating - customEvent/index.ts is now a one-line call into a new customEventAction(title, description, defaultSubscription?) factory in action.ts, sharing buildEventAction's fields/perform/ performBatch logic with standardEventAction - buildEventAction takes two named optional fields (trackingTypeField, customEventNameField) instead of a generic fields bag - exactly one is passed per action variant, the other stays undefined and is omitted from fields - resolveVersion no longer checks the Flagon canary flag - version selection is based purely on the payload's api_version value now. Removed isCanary/FLAGON_NAME (dead code) and the now-meaningless features:{...} from test payloads Co-Authored-By: Claude Sonnet 5 --- .../__tests__/index.test.ts | 7 +- .../reddit-conversions-api/action.ts | 87 ++++++++++++------- .../customEvent/index.ts | 57 +----------- .../reddit-conversions-api/v3/utils-v3.ts | 6 -- 4 files changed, 61 insertions(+), 96 deletions(-) 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 6eec9b3c807..dd220203b2c 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,7 +2,6 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' -import { FLAGON_NAME } from '../v3/utils-v3' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' @@ -937,7 +936,6 @@ describe('Reddit Conversions Api', () => { event, settings, useDefaultMappings: true, - features: { [FLAGON_NAME]: true }, mapping: { tracking_type: 'Purchase', api_version: 'v3', @@ -996,7 +994,6 @@ describe('Reddit Conversions Api', () => { event, settings, useDefaultMappings: true, - features: { [FLAGON_NAME]: true }, mapping: { custom_event_name: 'Some Custom Event Name', api_version: 'v3', @@ -1009,7 +1006,7 @@ describe('Reddit Conversions Api', () => { expect(body.data.events[0].type.tracking_type).toBe('CUSTOM') }) - it('should stay on v2 when the flag is on but api_version is not set (existing customers)', async () => { + it('should stay on v2 when api_version is not set (existing customers)', async () => { const event = createTestEvent({ timestamp: timestamp, event: 'Order Completed', @@ -1024,7 +1021,6 @@ describe('Reddit Conversions Api', () => { event, settings, useDefaultMappings: true, - features: { [FLAGON_NAME]: true }, // See the comment in the "dedicated event actions" describe block below: useDefaultMappings // would otherwise merge in api_version's own default ('v3'), which isn't what an existing, // pre-this-field customer's stored mapping looks like. @@ -1053,7 +1049,6 @@ describe('Reddit Conversions Api', () => { event, settings, useDefaultMappings: true, - features: { [FLAGON_NAME]: true }, mapping: { tracking_type: 'Purchase', api_version: 'v3' diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts index fddf2d8d81d..e8d5ee2b7f7 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts @@ -1,12 +1,14 @@ -import type { ActionDefinition, Features } from '@segment/actions-core' +import type { ActionDefinition, InputField } 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 { send } from './utils' -import { sendV3, FLAGON_NAME } from './v3/utils-v3' +import { sendV3 } from './v3/utils-v3' import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from './versioning-info' import { event_at, tracking_type, + custom_event_name, click_id, products, user, @@ -20,33 +22,26 @@ import { test_id } from './fields' -export function isCanary(features?: Features): boolean { - return features?.[FLAGON_NAME] ?? false -} - /** resolveVersion - * Decides which Reddit Conversions API version a given event should use. - * - * The Flagon flag is the ops-level rollout/kill-switch: if it's off, every event stays on v2, - * regardless of the customer's `api_version` field selection. If the flag is on, the customer's - * per-action `api_version` field decides: existing customers who configured this action before - * the field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, + * Decides which Reddit Conversions API version a given event should use, based on the + * per-action `api_version` field. Existing customers who configured this action before the + * field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, * until they explicitly opt into `'v3'`. */ -export function resolveVersion(apiVersion: string | undefined, features?: Features): ApiVersion { - if (!isCanary(features)) { - return LEGACY_API_VERSION - } +export function resolveVersion(apiVersion: string | undefined): ApiVersion { return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION } -export function standardEventAction( - // When set, tracking_type is hardcoded to this value and hidden from the UI, replicating - // the old per-event-type presets. When undefined, tracking_type stays a user-selectable field. - trackingType: string | undefined, +function buildEventAction( + // Exactly one of these is passed per action variant; the other stays undefined and is omitted + // from fields. trackingTypeField is also omitted for standardEvent's hardcoded-trackingType + // presets, where the value is fixed rather than user-selectable. + trackingTypeField: InputField | undefined, + customEventNameField: InputField | undefined, title: string, description: string, - defaultSubscription?: string + defaultSubscription: string | undefined, + resolvePayload: (payload: Payload) => Payload ): ActionDefinition { return { title, @@ -54,7 +49,8 @@ export function standardEventAction( defaultSubscription, fields: { event_at, - ...(trackingType ? {} : { tracking_type }), + ...(trackingTypeField ? { tracking_type: trackingTypeField } : {}), + ...(customEventNameField ? { custom_event_name: customEventNameField } : {}), click_id, products, user, @@ -67,16 +63,16 @@ export function standardEventAction( event_source_url, test_id }, - perform: async (request, { settings, payload, features }) => { - const resolvedPayload = trackingType ? { ...payload, tracking_type: trackingType } : payload - return resolveVersion(payload.api_version, features) === LATEST_API_VERSION + perform: async (request, { settings, payload }) => { + const resolvedPayload = resolvePayload(payload) as StandardEvent + return resolveVersion(payload.api_version) === LATEST_API_VERSION ? sendV3(request, settings, [resolvedPayload]) : send(request, settings, [resolvedPayload]) }, - performBatch: async (request, { settings, payload, features }) => { - const resolvedPayloads = trackingType ? payload.map((p) => ({ ...p, tracking_type: trackingType })) : payload - const v2Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) - const v3Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) + performBatch: async (request, { settings, payload }) => { + const resolvedPayloads = payload.map(resolvePayload) as StandardEvent[] + const v2Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version) === LEGACY_API_VERSION) + const v3Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version) === LATEST_API_VERSION) const requests = [] if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) @@ -84,3 +80,36 @@ export function standardEventAction( } } } + +export function standardEventAction( + // When set, tracking_type is hardcoded to this value and hidden from the UI, replicating + // the old per-event-type presets. When undefined, tracking_type stays a user-selectable field. + trackingType: string | undefined, + title: string, + description: string, + defaultSubscription?: string +): ActionDefinition { + return buildEventAction( + trackingType ? undefined : tracking_type, + undefined, + title, + description, + defaultSubscription, + (payload) => (trackingType ? { ...payload, tracking_type: trackingType } : payload) + ) +} + +export function customEventAction( + title: string, + description: string, + defaultSubscription?: string +): ActionDefinition { + return buildEventAction( + undefined, + custom_event_name, + title, + description, + defaultSubscription, + (payload) => payload + ) +} 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 a6c1ffdaceb..f40d40e6c72 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 @@ -1,57 +1,4 @@ -import type { ActionDefinition } from '@segment/actions-core' -import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' -import { sendV3 } from '../v3/utils-v3' -import { resolveVersion } from '../action' -import { LEGACY_API_VERSION, LATEST_API_VERSION } from '../versioning-info' -import { - event_at, - custom_event_name, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id, - api_version, - action_source, - event_source_url, - test_id -} from '../fields' +import { customEventAction } from '../action' -const action: ActionDefinition = { - title: 'Send Custom Event', - description: 'Send a Custom Conversion Event to Reddit', - fields: { - event_at, - custom_event_name, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id, - api_version, - action_source, - event_source_url, - test_id - }, - perform: async (request, { settings, payload, features }) => { - return resolveVersion(payload.api_version, features) === LATEST_API_VERSION - ? sendV3(request, settings, [payload]) - : send(request, settings, [payload]) - }, - performBatch: async (request, { settings, payload, features }) => { - const v2Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LEGACY_API_VERSION) - const v3Payloads = payload.filter((p) => resolveVersion(p.api_version, features) === LATEST_API_VERSION) - const requests = [] - if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) - if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) - return Promise.all(requests) - } -} - -export default action +export default customEventAction('Send Custom Event', 'Send a Custom Conversion Event to Reddit') diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 26a84845a34..79e5394a996 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -7,12 +7,6 @@ import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessing import { processHashing } from '../../../lib/hashing-utils' import { LATEST_API_VERSION } from '../versioning-info' -/** FLAGON_NAME - * Flagon flag gating whether Reddit Conversions API v3 is reachable at all for an account. - * https://flagon.segment.com/families/centrifuge-destinations/gates/reddit-conversions-api-canary-version - */ -export const FLAGON_NAME = 'reddit-conversions-api-canary-version' - type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] From cc9f34bc625e09be1f7d5a070401c6f2ba0559cc Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 11:37:13 +0100 Subject: [PATCH 08/20] Add per-item batch validation via MultiStatusResponse for V3; lock down api_version and test_id - sendV3 now takes raw payloads + an isBatch flag: for a single event it throws on invalid input as before; for a batch it validates each payload independently (via the merged createRedditPayloadV3), sends only the valid ones in one request, and reports per-item validation errors through a MultiStatusResponse instead of failing the whole batch. V2 batches are unaffected - homogeneous per-mapping api_version means a batch is never mixed V2/V3 - Add ProductV3.id required-id validation (throws if missing/blank) now that the type requires it; V2's getProducts gains matching quantity/item_price mapping - New v3/constants.ts: ACTION_SOURCE_V3 (+ labels) and EVENT_TYPE_V3, used to type-narrow action_source/tracking_type in types-v3.ts and to generate the action_source field's choices in fields.ts - api_version field: disabledInputMethods locks it to the choices dropdown only - customers can no longer map it from event payload data - test_id moved from an action-level field to a destination-level Settings field (settings are never payload-mapped, so this was a cleaner way to guarantee literal-only entry than restricting the action field directly) - Consolidated duplicate helpers (clean, cleanNum, getAdId, getDataProcessingOptions, getScreen, getUser, canonicalizeEmail, smartHash, cleanPhoneNumber) into utils.ts, exported and reused from v3/utils-v3.ts instead of being duplicated Co-Authored-By: Claude Sonnet 5 --- .../__tests__/index.test.ts | 5 +- .../reddit-conversions-api/action.ts | 29 +- .../customEvent/generated-types.ts | 4 - .../reddit-conversions-api/fields.ts | 20 +- .../reddit-conversions-api/generated-types.ts | 6 +- .../reddit-conversions-api/index.ts | 9 +- .../standardEvent/generated-types.ts | 4 - .../reddit-conversions-api/types.ts | 2 + .../reddit-conversions-api/utils.ts | 41 +-- .../reddit-conversions-api/v3/constants.ts | 33 +++ .../reddit-conversions-api/v3/types-v3.ts | 37 +-- .../reddit-conversions-api/v3/utils-v3.ts | 250 +++++++----------- 12 files changed, 199 insertions(+), 241 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts 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 dd220203b2c..460eb5a0f55 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 @@ -934,13 +934,12 @@ describe('Reddit Conversions Api', () => { nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, - settings, + settings: { ...settings, test_id: 'test-123' }, useDefaultMappings: true, mapping: { tracking_type: 'Purchase', api_version: 'v3', - action_source: 'WEBSITE', - test_id: 'test-123' + action_source: 'WEBSITE' } }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts index e8d5ee2b7f7..9ccf484184f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts @@ -18,24 +18,14 @@ import { conversion_id, api_version, action_source, - event_source_url, - test_id + event_source_url } from './fields' -/** resolveVersion - * Decides which Reddit Conversions API version a given event should use, based on the - * per-action `api_version` field. Existing customers who configured this action before the - * field existed have no value for it (resolves to `undefined` here), so they stay on v2 too, - * until they explicitly opt into `'v3'`. - */ export function resolveVersion(apiVersion: string | undefined): ApiVersion { return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION } function buildEventAction( - // Exactly one of these is passed per action variant; the other stays undefined and is omitted - // from fields. trackingTypeField is also omitted for standardEvent's hardcoded-trackingType - // presets, where the value is fixed rather than user-selectable. trackingTypeField: InputField | undefined, customEventNameField: InputField | undefined, title: string, @@ -60,23 +50,22 @@ function buildEventAction( conversion_id, api_version, action_source, - event_source_url, - test_id + event_source_url }, perform: async (request, { settings, payload }) => { const resolvedPayload = resolvePayload(payload) as StandardEvent return resolveVersion(payload.api_version) === LATEST_API_VERSION - ? sendV3(request, settings, [resolvedPayload]) + ? sendV3(request, settings, [resolvedPayload], false) : send(request, settings, [resolvedPayload]) }, performBatch: async (request, { settings, payload }) => { const resolvedPayloads = payload.map(resolvePayload) as StandardEvent[] - const v2Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version) === LEGACY_API_VERSION) - const v3Payloads = resolvedPayloads.filter((p) => resolveVersion(p.api_version) === LATEST_API_VERSION) - const requests = [] - if (v2Payloads.length) requests.push(send(request, settings, v2Payloads)) - if (v3Payloads.length) requests.push(sendV3(request, settings, v3Payloads)) - return Promise.all(requests) + + // api_version is a static per-mapping setting, not derived from event data, so a batch is + // always homogeneously all-V2 or all-V3 - checking the first payload is enough. + return resolveVersion(resolvedPayloads[0]?.api_version) === LATEST_API_VERSION + ? sendV3(request, settings, resolvedPayloads, true) + : send(request, settings, resolvedPayloads) } } } 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 713899c4062..1bd61ef92aa 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 @@ -138,8 +138,4 @@ export interface Payload { * 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 (Beta). */ 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 (Beta). - */ - test_id?: string } 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 478a6772925..c073df45399 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -1,5 +1,6 @@ import { InputField } from '@segment/actions-core/destination-kit/types' import { LEGACY_API_VERSION, LATEST_API_VERSION } from './versioning-info' +import { ACTION_SOURCE_V3, ACTION_SOURCE_V3_LABELS } from './v3/constants' export const event_at: InputField = { label: 'Event At', @@ -47,7 +48,8 @@ export const api_version: InputField = { choices: [ { label: 'V3 (Beta)', value: LATEST_API_VERSION }, { label: 'V2', value: LEGACY_API_VERSION } - ] + ], + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'] } const API_VERSION_IS_V3 = { @@ -62,12 +64,7 @@ export const action_source: InputField = { type: 'string', required: API_VERSION_IS_V3, depends_on: API_VERSION_IS_V3, - choices: [ - { label: 'Website', value: 'WEBSITE' }, - { label: 'App', value: 'APP' }, - { label: 'Offline (Physical Store)', value: 'PHYSICAL_STORE' }, - { label: 'Other', value: 'OTHER' } - ] + choices: ACTION_SOURCE_V3.map((value) => ({ label: ACTION_SOURCE_V3_LABELS[value], value })) } export const event_source_url: InputField = { @@ -80,15 +77,6 @@ export const event_source_url: InputField = { 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 (Beta).', - type: 'string', - required: false, - depends_on: API_VERSION_IS_V3 -} - export const click_id: InputField = { label: 'Click ID', description: 'The Reddit-generated id associated with a single ad click.', diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts index 4bc5e5b80cb..807a42c35d4 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts @@ -10,7 +10,11 @@ export interface Settings { */ conversion_token: string /** - * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead. + * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead. */ test_mode?: boolean + /** + * 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 (Beta). + */ + test_id?: string } 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 af4529664d5..1a2a6da7220 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -29,10 +29,17 @@ const destination: DestinationDefinition = { test_mode: { label: 'Test Mode', description: - 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.', + 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead.', type: 'boolean', required: false, default: 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 (Beta).', + type: 'string', + required: false } }, testAuthentication: async (request, { settings }) => { 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 aaaeb231f51..ab7f46a328f 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 @@ -138,8 +138,4 @@ export interface Payload { * 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 (Beta). */ 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 (Beta). - */ - test_id?: string } 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..055cab4c4f5 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -13,6 +13,8 @@ export interface Product { category?: string id?: string name?: string + quantity?: number + item_price?: number } export interface EventMetadata { 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 f710e6d84a7..a59d55bf4ea 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -22,14 +22,11 @@ type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['sc export async function send(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { const data = createRedditPayload(payload, settings) - return request( - `https://ads-api.reddit.com/api/${LEGACY_API_VERSION}/conversions/events/${settings.ad_account_id}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${settings.conversion_token}` }, - json: JSON.parse(JSON.stringify(data)) - } - ) + return request(`https://ads-api.reddit.com/api/${LEGACY_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 { @@ -73,12 +70,12 @@ function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings } } -function clean(str: string | undefined): string | undefined { +export 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 { +export function cleanNum(num: number | undefined): number | undefined { if (num === undefined || num === null) return undefined return num } @@ -92,7 +89,9 @@ function getProducts(products: ProductsType): Product[] | undefined { return { category: clean(product.category), id: clean(product.id), - name: clean(product.name) + name: clean(product.name), + quantity: cleanNum(product.quantity), + item_price: cleanNum(product.item_price) } }) } @@ -115,14 +114,17 @@ function getMetadata( } } -function getAdId(device_type?: string, advertising_id?: string): { [key: string]: string | undefined } | undefined { +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 } } -function getDataProcessingOptions( +export function getDataProcessingOptions( dataProcessingOptions: DataProcessingOptionsType ): DatapProcessingOptions | undefined { if (!dataProcessingOptions) return undefined @@ -133,7 +135,7 @@ function getDataProcessingOptions( } } -function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { +export function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { if (height === undefined || width === undefined) return undefined return { height, @@ -141,7 +143,7 @@ function getScreen(height?: number, width?: number): { height: number; width: nu } } -function getUser( +export function getUser( user: UserType, dataProcessingOptions: DataProcessingOptionsType, screenDimensions: ScreenDimensionsType @@ -161,19 +163,22 @@ function getUser( } } -function canonicalizeEmail(value: string): string { +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()}` } -const smartHash = (value: string | undefined, cleaningFunction?: (value: string) => string): string | undefined => { +export 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 { +export function cleanPhoneNumber(phoneNumber: string): string { if (!phoneNumber) return '' phoneNumber = phoneNumber.trim() const prefix = '+' diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts new file mode 100644 index 00000000000..c4d0a2a6279 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts @@ -0,0 +1,33 @@ +export const ACTION_SOURCE_V3 = ['WEBSITE', 'APP', 'OTHER', 'PHYSICAL_STORE'] as const + +export const ACTION_SOURCE_V3_LABELS: Record = { + WEBSITE: 'Website', + APP: 'App', + OTHER: 'Other', + PHYSICAL_STORE: 'Offline (Physical Store)' +} + +export const EVENT_TYPE_V3 = [ + 'PAGE_VISIT', + 'VIEW_CONTENT', + 'SEARCH', + 'ADD_TO_CART', + 'ADD_TO_WISHLIST', + 'PURCHASE', + 'LEAD', + 'SIGN_UP', + 'CUSTOM' +] as const + +// v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. +export 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' +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts index 21c97820de7..492119e3466 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts @@ -1,26 +1,31 @@ -export interface V3Product { +import { ACTION_SOURCE_V3, EVENT_TYPE_V3 } from './constants' + +export type ActionSourceV3 = typeof ACTION_SOURCE_V3[number] +export type EventTypeV3 = typeof EVENT_TYPE_V3[number] + +export interface ProductV3 { category?: string - id?: string + id: string name?: string quantity?: number item_price?: number } -export interface V3Metadata { +export interface MetadataV3 { currency?: string item_count?: number value?: number conversion_id?: string - products?: Array + products?: Array } -export interface V3DataProcessingOptions { +export interface DataProcessingOptionsV3 { country?: string modes?: string[] region?: string } -export interface V3User { +export interface UserV3 { idfa?: string aaid?: string email?: string @@ -28,7 +33,7 @@ export interface V3User { ip_address?: string user_agent?: string uuid?: string - data_processing_options?: V3DataProcessingOptions + data_processing_options?: DataProcessingOptionsV3 screen_dimensions?: { height?: number width?: number @@ -36,22 +41,22 @@ export interface V3User { phone_number?: string } -export interface V3EventItem { - event_at: number - action_source?: string +export interface EventItemV3 { + event_at: number // milliseconds + action_source: ActionSourceV3 event_source_url?: string click_id?: string type: { - tracking_type: string - custom_event_name?: string + tracking_type: EventTypeV3 + custom_event_name?: string // required if tracking_type is CUSTOM } - event_metadata?: V3Metadata - user?: V3User + event_metadata?: MetadataV3 + user?: UserV3 } -export interface V3Payload { +export interface PayloadV3 { data: { - events: V3EventItem[] + events: EventItemV3[] partner: 'SEGMENT' test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 79e5394a996..f87786289c1 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -1,48 +1,67 @@ -import type { RequestClient } from '@segment/actions-core' -import { PayloadValidationError } from '@segment/actions-core' +import type { RequestClient, JSONLikeObject } from '@segment/actions-core' +import { PayloadValidationError, MultiStatusResponse } 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 { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types-v3' -import { processHashing } from '../../../lib/hashing-utils' +import { EventItemV3, PayloadV3, MetadataV3, ProductV3, ActionSourceV3, EventTypeV3 } from './types-v3' +import { ACTION_SOURCE_V3, TRACKING_TYPE_V3 } from './constants' +import { clean, cleanNum, getUser, smartHash } from '../utils' import { LATEST_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'] -const V3_URL = (adAccountId: string) => - `https://ads-api.reddit.com/api/${LATEST_API_VERSION}/pixels/${adAccountId}/conversion_events` +export async function sendV3( + request: RequestClient, + settings: Settings, + payloads: (StandardEvent | CustomEvent)[], + isBatch: boolean +) { + const multiStatusResponse = new MultiStatusResponse() + const indices: number[] = [] + const events: EventItemV3[] = [] + + payloads.forEach((payload, index) => { + const { error, event } = createRedditPayloadV3(payload) + if (error) { + if (!isBatch) { + throw new PayloadValidationError(error) + } + multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errormessage: error }) + } else { + indices.push(index) + events.push(event as EventItemV3) + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: events[indices.indexOf(index)] as unknown as JSONLikeObject, + body: { success: true } + }) + } + }) -// 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' -} + if (events.length) { + const data: PayloadV3 = { data: { events, partner: 'SEGMENT', test_id: clean(settings.test_id) } } + const response = await request( + `https://ads-api.reddit.com/api/${LATEST_API_VERSION}/pixels/${settings.ad_account_id}/conversion_events`, + { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + } + ) + if (!isBatch) { + return response + } + } -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)) - }) + return multiStatusResponse } -function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Payload { - const test_id = clean((payloads[0] as StandardEvent | CustomEvent)?.test_id) - - const events: V3EventItem[] = payloads.map((payload) => { +function createRedditPayloadV3( + payload: StandardEvent | CustomEvent +): { error: string; event?: undefined } | { error?: undefined; event: EventItemV3 } { + try { const { event_at, click_id, @@ -59,87 +78,83 @@ function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Pay 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) { - // The `action_source` field is conditionally required (only when `api_version` is `'v3'`), - // so schema validation should already guarantee this is present by the time we get here. - // This is a defensive backstop, not the primary enforcement mechanism. - throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') - } - 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 - }, - event_metadata: getMetadata(event_metadata, products, conversion_id), - user: getUser(user, data_processing_options, screen_dimensions) + event: { + event_at: toEpochMs(event_at), + action_source: toActionSourceV3(action_source), + event_source_url: clean(event_source_url), + click_id: clean(click_id), + type: { + tracking_type: toV3TrackingType(tracking_type), + custom_event_name + }, + event_metadata: getMetadata(event_metadata, products, conversion_id), + user: getUser(user, data_processing_options, screen_dimensions) + } } - }) - - return { data: { events, partner: 'SEGMENT', test_id } } + } catch (err) { + return { error: err instanceof Error ? err.message : 'Invalid payload for Reddit Conversions API v3' } + } } -// 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 { + const EPOCH_MS_MIN = 1e12 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 + if (typeof value === 'string') { + const trimmed = value.trim() + const isDigitsOnly = /^\d+$/.test(trimmed) + if (isDigitsOnly && Number(trimmed) >= EPOCH_MS_MIN) return Number(trimmed) + if (!isDigitsOnly) { + 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 { +function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { 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 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 toActionSourceV3(action_source: string | undefined): ActionSourceV3 { + if (!action_source) + throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') + if (!(ACTION_SOURCE_V3 as readonly string[]).includes(action_source)) { + throw new PayloadValidationError(`Unsupported action_source: ${action_source}`) + } + return action_source as ActionSourceV3 } -function getProducts(products: ProductsType): V3Product[] | undefined { +function getProducts(products: ProductsType): ProductV3[] | undefined { if (!products) return undefined return products.map((product) => ({ category: clean(product.category), - id: clean(product.id), + id: toProductIdV3(product.id), name: clean(product.name), quantity: cleanNum(product.quantity), item_price: cleanNum(product.item_price) })) } +function toProductIdV3(id: string | undefined): string { + const cleaned = clean(id) + if (!cleaned) throw new PayloadValidationError('products.id is required when sending to Reddit Conversions API v3') + return cleaned +} + function getMetadata( metadata: EventMetadataType, products: ProductsType, conversion_id: ConversionIdType -): V3Metadata | undefined { +): MetadataV3 | undefined { if (!metadata && !products && !conversion_id) return undefined return { currency: clean(metadata?.currency), @@ -151,84 +166,3 @@ function getMetadata( 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 -): V3DataProcessingOptions | 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 -): 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) - } -} - -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 -} From 61c98d7b21365908b11d177ff499f3b430901e0a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 11:41:31 +0100 Subject: [PATCH 09/20] Restructure createRedditPayloadV3 to mirror the legacy whole-batch flow createRedditPayloadV3 now takes the whole payloads array (plus settings, multiStatusResponse, isBatch) and builds the full PayloadV3 envelope internally - looping, validating, and populating the MultiStatusResponse per item - the same shape as v2's createRedditPayload(payloads, settings). sendV3 is simplified to: build the payload, POST if there's anything to send, and return appropriately. Co-Authored-By: Claude Sonnet 5 --- .../reddit-conversions-api/v3/utils-v3.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index f87786289c1..06f421fd434 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -19,29 +19,9 @@ export async function sendV3( isBatch: boolean ) { const multiStatusResponse = new MultiStatusResponse() - const indices: number[] = [] - const events: EventItemV3[] = [] - - payloads.forEach((payload, index) => { - const { error, event } = createRedditPayloadV3(payload) - if (error) { - if (!isBatch) { - throw new PayloadValidationError(error) - } - multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errormessage: error }) - } else { - indices.push(index) - events.push(event as EventItemV3) - multiStatusResponse.setSuccessResponseAtIndex(index, { - status: 200, - sent: events[indices.indexOf(index)] as unknown as JSONLikeObject, - body: { success: true } - }) - } - }) + const data = createRedditPayloadV3(payloads, settings, multiStatusResponse, isBatch) - if (events.length) { - const data: PayloadV3 = { data: { events, partner: 'SEGMENT', test_id: clean(settings.test_id) } } + if (data.data.events.length) { const response = await request( `https://ads-api.reddit.com/api/${LATEST_API_VERSION}/pixels/${settings.ad_account_id}/conversion_events`, { @@ -59,27 +39,33 @@ export async function sendV3( } function createRedditPayloadV3( - payload: StandardEvent | CustomEvent -): { error: string; event?: undefined } | { error?: undefined; event: EventItemV3 } { - try { - 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 - - return { - event: { + payloads: (StandardEvent | CustomEvent)[], + settings: Settings, + multiStatusResponse: MultiStatusResponse, + isBatch: boolean +): PayloadV3 { + const indices: number[] = [] + const events: EventItemV3[] = [] + + payloads.forEach((payload, index) => { + try { + 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 + + const event: EventItemV3 = { event_at: toEpochMs(event_at), action_source: toActionSourceV3(action_source), event_source_url: clean(event_source_url), @@ -91,10 +77,24 @@ function createRedditPayloadV3( event_metadata: getMetadata(event_metadata, products, conversion_id), user: getUser(user, data_processing_options, screen_dimensions) } + + indices.push(index) + events.push(event) + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: events[indices.indexOf(index)] as unknown as JSONLikeObject, + body: { success: true } + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Invalid payload for Reddit Conversions API v3' + if (!isBatch) { + throw new PayloadValidationError(error) + } + multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errormessage: error }) } - } catch (err) { - return { error: err instanceof Error ? err.message : 'Invalid payload for Reddit Conversions API v3' } - } + }) + + return { data: { events, partner: 'SEGMENT', test_id: clean(settings.test_id) } } } export function toEpochMs(value: string | number | undefined): number { From dde0afcf56ef720778264ad1d3c8e214e501d944 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 11:53:30 +0100 Subject: [PATCH 10/20] Dedupe EVENT_TYPE_V3 into TRACKING_TYPE_V3 EVENT_TYPE_V3 was only ever used to type TRACKING_TYPE_V3's values and derive EventTypeV3 - never as an array in its own right, and its values duplicated TRACKING_TYPE_V3's values exactly. Made TRACKING_TYPE_V3 `as const` and derive EventTypeV3 from its values directly instead. Co-Authored-By: Claude Sonnet 5 --- .../reddit-conversions-api/v3/constants.ts | 16 ++-------------- .../reddit-conversions-api/v3/types-v3.ts | 4 ++-- .../reddit-conversions-api/v3/utils-v3.ts | 2 +- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts index c4d0a2a6279..027a1bf6a1e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts @@ -7,20 +7,8 @@ export const ACTION_SOURCE_V3_LABELS: Record v3 UPPER_SNAKE_CASE. -export const TRACKING_TYPE_V3: Record = { +export const TRACKING_TYPE_V3 = { PageVisit: 'PAGE_VISIT', ViewContent: 'VIEW_CONTENT', Search: 'SEARCH', @@ -30,4 +18,4 @@ export const TRACKING_TYPE_V3: Record = { Lead: 'LEAD', SignUp: 'SIGN_UP', Custom: 'CUSTOM' -} +} as const diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts index 492119e3466..afa2de1df80 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts @@ -1,7 +1,7 @@ -import { ACTION_SOURCE_V3, EVENT_TYPE_V3 } from './constants' +import { ACTION_SOURCE_V3, TRACKING_TYPE_V3 } from './constants' export type ActionSourceV3 = typeof ACTION_SOURCE_V3[number] -export type EventTypeV3 = typeof EVENT_TYPE_V3[number] +export type EventTypeV3 = typeof TRACKING_TYPE_V3[keyof typeof TRACKING_TYPE_V3] export interface ProductV3 { category?: string diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 06f421fd434..b808a811f1c 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -119,7 +119,7 @@ export function toEpochMs(value: string | number | undefined): number { function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { if (!tracking_type) throw new PayloadValidationError('tracking_type is required') - const mapped = TRACKING_TYPE_V3[tracking_type] + const mapped = (TRACKING_TYPE_V3 as Record)[tracking_type] if (!mapped) throw new PayloadValidationError(`Unsupported tracking_type: ${tracking_type}`) return mapped } From c329461b6ce0e37537b9a26b47b343c0bed4b26f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 12:00:17 +0100 Subject: [PATCH 11/20] Dedupe ACTION_SOURCE_V3 into ACTION_SOURCE_V3_LABELS Same pattern as the earlier EVENT_TYPE_V3/TRACKING_TYPE_V3 dedupe: ACTION_SOURCE_V3_LABELS was already keyed by the same values ACTION_SOURCE_V3 held, so the array was pure duplication. Derive ActionSourceV3 from the labels object's keys instead, build fields.ts's choices via Object.entries(), and validate via the `in` operator. Co-Authored-By: Claude Sonnet 5 --- .../src/destinations/reddit-conversions-api/fields.ts | 4 ++-- .../src/destinations/reddit-conversions-api/v3/constants.ts | 6 ++---- .../src/destinations/reddit-conversions-api/v3/types-v3.ts | 4 ++-- .../src/destinations/reddit-conversions-api/v3/utils-v3.ts | 4 ++-- 4 files changed, 8 insertions(+), 10 deletions(-) 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 c073df45399..d202d08a97a 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -1,6 +1,6 @@ import { InputField } from '@segment/actions-core/destination-kit/types' import { LEGACY_API_VERSION, LATEST_API_VERSION } from './versioning-info' -import { ACTION_SOURCE_V3, ACTION_SOURCE_V3_LABELS } from './v3/constants' +import { ACTION_SOURCE_V3_LABELS } from './v3/constants' export const event_at: InputField = { label: 'Event At', @@ -64,7 +64,7 @@ export const action_source: InputField = { type: 'string', required: API_VERSION_IS_V3, depends_on: API_VERSION_IS_V3, - choices: ACTION_SOURCE_V3.map((value) => ({ label: ACTION_SOURCE_V3_LABELS[value], value })) + choices: Object.entries(ACTION_SOURCE_V3_LABELS).map(([value, label]) => ({ label, value })) } export const event_source_url: InputField = { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts index 027a1bf6a1e..15ef1cd455b 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts @@ -1,11 +1,9 @@ -export const ACTION_SOURCE_V3 = ['WEBSITE', 'APP', 'OTHER', 'PHYSICAL_STORE'] as const - -export const ACTION_SOURCE_V3_LABELS: Record = { +export const ACTION_SOURCE_V3_LABELS = { WEBSITE: 'Website', APP: 'App', OTHER: 'Other', PHYSICAL_STORE: 'Offline (Physical Store)' -} +} as const // v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. export const TRACKING_TYPE_V3 = { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts index afa2de1df80..1d0e0d7c94c 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts @@ -1,6 +1,6 @@ -import { ACTION_SOURCE_V3, TRACKING_TYPE_V3 } from './constants' +import { ACTION_SOURCE_V3_LABELS, TRACKING_TYPE_V3 } from './constants' -export type ActionSourceV3 = typeof ACTION_SOURCE_V3[number] +export type ActionSourceV3 = keyof typeof ACTION_SOURCE_V3_LABELS export type EventTypeV3 = typeof TRACKING_TYPE_V3[keyof typeof TRACKING_TYPE_V3] export interface ProductV3 { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index b808a811f1c..b0f3c929acc 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -4,7 +4,7 @@ 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 { EventItemV3, PayloadV3, MetadataV3, ProductV3, ActionSourceV3, EventTypeV3 } from './types-v3' -import { ACTION_SOURCE_V3, TRACKING_TYPE_V3 } from './constants' +import { ACTION_SOURCE_V3_LABELS, TRACKING_TYPE_V3 } from './constants' import { clean, cleanNum, getUser, smartHash } from '../utils' import { LATEST_API_VERSION } from '../versioning-info' @@ -127,7 +127,7 @@ function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { function toActionSourceV3(action_source: string | undefined): ActionSourceV3 { if (!action_source) throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') - if (!(ACTION_SOURCE_V3 as readonly string[]).includes(action_source)) { + if (!(action_source in ACTION_SOURCE_V3_LABELS)) { throw new PayloadValidationError(`Unsupported action_source: ${action_source}`) } return action_source as ActionSourceV3 From b2a796aa384cae3b2e6eef05e27c8c80ab3e4995 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 12:25:22 +0100 Subject: [PATCH 12/20] Revert test files to main Reset __tests__/index.test.ts and all snapshot.test.ts.snap files back to main's version - none of this branch's test coverage changes are kept for now. Co-Authored-By: Claude Sonnet 5 --- .../__snapshots__/snapshot.test.ts.snap | 532 +----------------- .../__tests__/index.test.ts | 219 ++----- .../__snapshots__/snapshot.test.ts.snap | 2 + .../__snapshots__/snapshot.test.ts.snap | 2 + 4 files changed, 43 insertions(+), 712 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap index f79ce65c4a4..82c1b98383a 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,137 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Testing snapshot for actions-reddit-conversions-api destination: addToCart action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "p#r(0K#rxAAl(v)^z", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", - "currency": "SHP", - "item_count": 4702971690483712, - "products": Array [ - Object { - "category": "p#r(0K#rxAAl(v)^z", - "id": "p#r(0K#rxAAl(v)^z", - "name": "p#r(0K#rxAAl(v)^z", - }, - ], - "value_decimal": 47029716904837.12, - }, - "event_type": Object { - "tracking_type": "AddToCart", - }, - "user": Object { - "aaid": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", - "data_processing_options": Object { - "country": "RS", - "modes": Array [ - "LDU", - ], - "region": "p#r(0K#rxAAl(v)^z", - }, - "email": "a406eef9f73ae55d785d1835781c825452fa48024c9f8e1e4f5ec605431d28fb", - "external_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", - "ip_address": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", - "phone_number": "c101893d61ca9787edac49beedc0cb15dcc86c6495402b634963bcafdc7c925a", - "screen_dimensions": Object { - "height": 4702971690483712, - "width": 4702971690483712, - }, - "user_agent": "p#r(0K#rxAAl(v)^z", - "uuid": "p#r(0K#rxAAl(v)^z", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: addToCart action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "p#r(0K#rxAAl(v)^z", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "448eedc9c255cc5add0a5dc99c29d361d8e958c5ecf7d02a83c6925cfd4d943d", - }, - "event_type": Object { - "tracking_type": "AddToCart", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: addToWishlist action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "fuz[PT)N!tq14MWd#v", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", - "currency": "THB", - "item_count": 5745118342545408, - "products": Array [ - Object { - "category": "fuz[PT)N!tq14MWd#v", - "id": "fuz[PT)N!tq14MWd#v", - "name": "fuz[PT)N!tq14MWd#v", - }, - ], - "value_decimal": 57451183425454.08, - }, - "event_type": Object { - "tracking_type": "AddToWishlist", - }, - "user": Object { - "aaid": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", - "data_processing_options": Object { - "country": "SN", - "modes": Array [ - "LDU", - ], - "region": "fuz[PT)N!tq14MWd#v", - }, - "email": "f46bc5e064354c63dad112ee067db9299d4d5cdc296d65141031284d8069ded8", - "external_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", - "ip_address": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", - "phone_number": "517d3db2ccc6219d2d77a095f669ed393315267838df3a902cdabd7784f2d780", - "screen_dimensions": Object { - "height": 5745118342545408, - "width": 5745118342545408, - }, - "user_agent": "fuz[PT)N!tq14MWd#v", - "uuid": "fuz[PT)N!tq14MWd#v", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: addToWishlist action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "fuz[PT)N!tq14MWd#v", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "9abb6d3de4c839a34790d44ee626f1ea0281aac619fb1c8c737a8d6fa94a97b7", - }, - "event_type": Object { - "tracking_type": "AddToWishlist", - }, - }, - ], - "partner": "SEGMENT", -} -`; - exports[`Testing snapshot for actions-reddit-conversions-api destination: customEvent action - all fields 1`] = ` Object { "events": Array [ @@ -178,6 +46,7 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": true, } `; @@ -197,336 +66,7 @@ Object { }, ], "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: lead action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "WThh$KLW6%(Lw!jiO]q", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", - "currency": "UYW", - "item_count": 7322245504434176, - "products": Array [ - Object { - "category": "WThh$KLW6%(Lw!jiO]q", - "id": "WThh$KLW6%(Lw!jiO]q", - "name": "WThh$KLW6%(Lw!jiO]q", - }, - ], - "value_decimal": 73222455044341.77, - }, - "event_type": Object { - "tracking_type": "Lead", - }, - "user": Object { - "aaid": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", - "data_processing_options": Object { - "country": "TT", - "modes": Array [ - "LDU", - ], - "region": "WThh$KLW6%(Lw!jiO]q", - }, - "email": "2e4a86bd5a63a56435cb6d2baebf1a30a6b58e45a78d15fe567e66f1f81ce46d", - "external_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", - "ip_address": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", - "phone_number": "f6ea26ab26c3eb154996c9900ca7b9d960a9a35603310fb3b40b35f51d68e01b", - "screen_dimensions": Object { - "height": 7322245504434176, - "width": 7322245504434176, - }, - "user_agent": "WThh$KLW6%(Lw!jiO]q", - "uuid": "WThh$KLW6%(Lw!jiO]q", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: lead action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "WThh$KLW6%(Lw!jiO]q", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "b20c6c53b102653d1466290ee7ef6656c8a152839a9340d42fb7b304210affbb", - }, - "event_type": Object { - "tracking_type": "Lead", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: pageVisit action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "7B$qESc", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", - "currency": "CDF", - "item_count": -6062670935490560, - "products": Array [ - Object { - "category": "7B$qESc", - "id": "7B$qESc", - "name": "7B$qESc", - }, - ], - "value_decimal": -60626709354905.6, - }, - "event_type": Object { - "tracking_type": "PageVisit", - }, - "user": Object { - "aaid": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", - "data_processing_options": Object { - "country": "CF", - "modes": Array [ - "LDU", - ], - "region": "7B$qESc", - }, - "email": "4dc02b43a09caa28090093c65393bb6b8946576c1cbe0f8a6d1001543ceea5bd", - "external_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", - "ip_address": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", - "phone_number": "da91a9a6e4c52003a0781ce0a1ac648ab6e72df48de4f23c1d2984cf9e2e52c1", - "screen_dimensions": Object { - "height": -6062670935490560, - "width": -6062670935490560, - }, - "user_agent": "7B$qESc", - "uuid": "7B$qESc", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: pageVisit action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "7B$qESc", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "4f2239823d2f7e468866085272ce0bded63a0e236c3cb8f83869d80d24d2b239", - }, - "event_type": Object { - "tracking_type": "PageVisit", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: purchase action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "KYja1m&ZYQ", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", - "currency": "IDR", - "item_count": -2400024885460992, - "products": Array [ - Object { - "category": "KYja1m&ZYQ", - "id": "KYja1m&ZYQ", - "name": "KYja1m&ZYQ", - }, - ], - "value_decimal": -24000248854609.92, - }, - "event_type": Object { - "tracking_type": "Purchase", - }, - "user": Object { - "aaid": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", - "data_processing_options": Object { - "country": "GU", - "modes": Array [ - "LDU", - ], - "region": "KYja1m&ZYQ", - }, - "email": "47ada54f61ac343e9be3c730c861d7c5615656ad9ec1b779a5f1590b24a43519", - "external_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", - "ip_address": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", - "phone_number": "e8c6111a6c5ce4814c99e9a0c8c576831793b3cb9e7d129f690b6f300ab9d600", - "screen_dimensions": Object { - "height": -2400024885460992, - "width": -2400024885460992, - }, - "user_agent": "KYja1m&ZYQ", - "uuid": "KYja1m&ZYQ", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: purchase action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "KYja1m&ZYQ", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "128fed12616f6f3e2d9693f90054d6feb81dbae5b8249c3c2781814d8b20916c", - }, - "event_type": Object { - "tracking_type": "Purchase", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: search action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "iWy&@8r4Oj28DP%5", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", - "currency": "SBD", - "item_count": 4106327638933504, - "products": Array [ - Object { - "category": "iWy&@8r4Oj28DP%5", - "id": "iWy&@8r4Oj28DP%5", - "name": "iWy&@8r4Oj28DP%5", - }, - ], - "value_decimal": 41063276389335.04, - }, - "event_type": Object { - "tracking_type": "Search", - }, - "user": Object { - "aaid": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", - "data_processing_options": Object { - "country": "PN", - "modes": Array [ - "LDU", - ], - "region": "iWy&@8r4Oj28DP%5", - }, - "email": "c8c37ddbecc769a0e7c7a1bff02a1f4a126b3b9135455dde3c20a059a45ec14a", - "external_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", - "ip_address": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", - "phone_number": "a216bca2681b91a26e7a31b540c7100e54ad93088280b3f5761012ed14e8d1cb", - "screen_dimensions": Object { - "height": 4106327638933504, - "width": 4106327638933504, - }, - "user_agent": "iWy&@8r4Oj28DP%5", - "uuid": "iWy&@8r4Oj28DP%5", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: search action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "iWy&@8r4Oj28DP%5", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "bbb9c495c6f05f8c12cbfd3f38b4fcfe23e0e8b946cf1ff84d3c87a8990f302c", - }, - "event_type": Object { - "tracking_type": "Search", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: signUp action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "pB*l&&$y3eW4B", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", - "currency": "MDL", - "item_count": 562951232684032, - "products": Array [ - Object { - "category": "pB*l&&$y3eW4B", - "id": "pB*l&&$y3eW4B", - "name": "pB*l&&$y3eW4B", - }, - ], - "value_decimal": 5629512326840.32, - }, - "event_type": Object { - "tracking_type": "SignUp", - }, - "user": Object { - "aaid": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", - "data_processing_options": Object { - "country": "LT", - "modes": Array [ - "LDU", - ], - "region": "pB*l&&$y3eW4B", - }, - "email": "dc4e7023fdf4f0acfd7dd43c5379581ab280b6f189d9423ea35257a6f6f55b01", - "external_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", - "ip_address": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", - "phone_number": "73758501a37e57515d77d2b2e323e687abf03ba299a87f91f53cfad50665a2f3", - "screen_dimensions": Object { - "height": 562951232684032, - "width": 562951232684032, - }, - "user_agent": "pB*l&&$y3eW4B", - "uuid": "pB*l&&$y3eW4B", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: signUp action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "pB*l&&$y3eW4B", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "c78095393925a78161e8e001b249ae1f2784297128c62b7c0140b50a43b984c1", - }, - "event_type": Object { - "tracking_type": "SignUp", - }, - }, - ], - "partner": "SEGMENT", + "test_mode": true, } `; @@ -575,6 +115,7 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": false, } `; @@ -593,71 +134,6 @@ Object { }, ], "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: viewContent action - all fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "OaH[Fid(DjO7DB2SWZ", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", - "currency": "TWD", - "item_count": 6698367690211328, - "products": Array [ - Object { - "category": "OaH[Fid(DjO7DB2SWZ", - "id": "OaH[Fid(DjO7DB2SWZ", - "name": "OaH[Fid(DjO7DB2SWZ", - }, - ], - "value_decimal": 66983676902113.28, - }, - "event_type": Object { - "tracking_type": "ViewContent", - }, - "user": Object { - "aaid": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", - "data_processing_options": Object { - "country": "TH", - "modes": Array [ - "LDU", - ], - "region": "OaH[Fid(DjO7DB2SWZ", - }, - "email": "de3399b808c0d5ef77de7a8ebe448d52cfc589255d422886ed5527203c0b98b9", - "external_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", - "ip_address": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", - "phone_number": "c37ec75a5e5691e5885e28fa159fd5d118517254b4941e41f8d14870406702e3", - "screen_dimensions": Object { - "height": 6698367690211328, - "width": 6698367690211328, - }, - "user_agent": "OaH[Fid(DjO7DB2SWZ", - "uuid": "OaH[Fid(DjO7DB2SWZ", - }, - }, - ], - "partner": "SEGMENT", -} -`; - -exports[`Testing snapshot for actions-reddit-conversions-api destination: viewContent action - required fields 1`] = ` -Object { - "events": Array [ - Object { - "click_id": "OaH[Fid(DjO7DB2SWZ", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "265b4e2dc0b0e7d18f5a2396298731c5acc64823f84623d7fb073d2e2b4053c5", - }, - "event_type": Object { - "tracking_type": "ViewContent", - }, - }, - ], - "partner": "SEGMENT", + "test_mode": false, } `; 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 460eb5a0f55..eb97ea50d7a 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 @@ -7,7 +7,8 @@ const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' const settings: Settings = { ad_account_id: 'ad_account_id_1', - conversion_token: 'conversion_token_1' + conversion_token: 'conversion_token_1', + test_mode: false } describe('Reddit Conversions Api', () => { @@ -47,8 +48,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - custom_event_name: 'Some Custom Event Name', - api_version: 'v2' + custom_event_name: 'Some Custom Event Name' } }) @@ -91,7 +91,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -130,8 +131,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - custom_event_name: 'Some Custom Event Name', - api_version: 'v2' + custom_event_name: 'Some Custom Event Name' } }) @@ -174,7 +174,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) }) @@ -216,8 +217,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Purchase', - api_version: 'v2' + tracking_type: 'Purchase' } }) @@ -259,7 +259,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -299,8 +300,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -341,7 +341,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -378,8 +379,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -419,7 +419,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -455,8 +456,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -494,7 +494,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -534,8 +535,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -576,7 +576,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -614,8 +615,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -656,7 +656,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -696,8 +697,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -738,7 +738,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -778,8 +779,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -820,7 +820,8 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) @@ -860,8 +861,7 @@ describe('Reddit Conversions Api', () => { settings, useDefaultMappings: true, mapping: { - tracking_type: 'Lead', - api_version: 'v2' + tracking_type: 'Lead' } }) @@ -902,158 +902,9 @@ describe('Reddit Conversions Api', () => { } } ], - partner: 'SEGMENT' + partner: 'SEGMENT', + test_mode: false }) }) }) - - describe('v3 (canary)', () => { - it('should send a Purchase Standard event to v3 when api_version is v3 and action_source is set', async () => { - const event = createTestEvent({ - timestamp: timestamp, - event: 'Order Completed', - messageId: 'test-message-id-contact', - type: 'track', - userId: 'user_id_1', - properties: { - click_id: 'click_id_1', - currency: 'USD', - quantity: 10, - revenue: 100, - uuid: 'uuid_1', - products: [{ product_id: 'product_id_1', category: 'category_1', name: 'name_1', quantity: 2, price: 25 }], - email: 'test@test.com' - }, - context: { - userAgent: 'test-user-agent', - ip: '111.111.111.111', - page: { url: 'https://example.com/checkout' } - } - }) - - nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) - const responses = await testDestination.testAction('standardEvent', { - event, - settings: { ...settings, test_id: 'test-123' }, - useDefaultMappings: true, - mapping: { - tracking_type: 'Purchase', - api_version: 'v3', - action_source: 'WEBSITE' - } - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - data: { - partner: 'SEGMENT', - test_id: 'test-123', - events: [ - { - action_source: 'WEBSITE', - event_source_url: 'https://example.com/checkout', - click_id: 'click_id_1', - event_at: 1704721970212, - type: { - tracking_type: 'PURCHASE' - }, - event_metadata: { - currency: 'USD', - item_count: 10, - value: 100, - products: [ - { - category: 'category_1', - id: 'product_id_1', - name: 'name_1', - quantity: 2, - item_price: 25 - } - ] - } - } - ] - } - }) - }) - - it('should route a Custom event to v3 with UPPER_SNAKE_CASE tracking_type', async () => { - const event = createTestEvent({ - timestamp: timestamp, - event: 'Some Custom Event Name', - messageId: 'test-message-id-contact', - type: 'track', - userId: 'user_id_1', - properties: {} - }) - - nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) - const responses = await testDestination.testAction('customEvent', { - event, - settings, - useDefaultMappings: true, - mapping: { - custom_event_name: 'Some Custom Event Name', - api_version: 'v3', - action_source: 'APP' - } - }) - - expect(responses.length).toBe(1) - const body = responses[0].options.json as { data: { events: Array<{ type: { tracking_type: string } }> } } - expect(body.data.events[0].type.tracking_type).toBe('CUSTOM') - }) - - it('should stay on v2 when api_version is not set (existing customers)', async () => { - const event = createTestEvent({ - timestamp: timestamp, - event: 'Order Completed', - messageId: 'test-message-id-contact', - type: 'track', - userId: 'user_id_1', - properties: { revenue: 100 } - }) - - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) - const responses = await testDestination.testAction('standardEvent', { - event, - settings, - useDefaultMappings: true, - // See the comment in the "dedicated event actions" describe block below: useDefaultMappings - // would otherwise merge in api_version's own default ('v3'), which isn't what an existing, - // pre-this-field customer's stored mapping looks like. - mapping: { - tracking_type: 'Purchase', - api_version: 'v2' - } - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - }) - - it('should reject the mapping when api_version is v3 but action_source is not set', async () => { - const event = createTestEvent({ - timestamp: timestamp, - event: 'Order Completed', - messageId: 'test-message-id-contact', - type: 'track', - userId: 'user_id_1', - properties: { revenue: 100 } - }) - - await expect( - testDestination.testAction('standardEvent', { - event, - settings, - useDefaultMappings: true, - mapping: { - tracking_type: 'Purchase', - api_version: 'v3' - } - }) - ).rejects.toThrow() - }) - }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 29ca8078e93..a1d96e2f9be 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -46,6 +46,7 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": false, } `; @@ -65,5 +66,6 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": false, } `; diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 15a993258eb..8dfcd05f7c1 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -45,6 +45,7 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": false, } `; @@ -63,5 +64,6 @@ Object { }, ], "partner": "SEGMENT", + "test_mode": false, } `; From b3d8999f31d0e690458c88ea2f43260bd188982a Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 12:27:56 +0100 Subject: [PATCH 13/20] updating snapshots --- .../__snapshots__/snapshot.test.ts.snap | 90 ++++++++++--------- .../__snapshots__/snapshot.test.ts.snap | 2 + .../__snapshots__/snapshot.test.ts.snap | 2 + 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap index 82c1b98383a..f3f3ddfef9a 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,51 +2,57 @@ exports[`Testing snapshot for actions-reddit-conversions-api destination: customEvent action - all fields 1`] = ` Object { - "events": Array [ - Object { - "click_id": "Q1Q49h4LP@", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "currency": "HNL", - "item_count": -2840640572358656, - "products": Array [ - Object { - "category": "Q1Q49h4LP@", - "id": "Q1Q49h4LP@", - "name": "Q1Q49h4LP@", - }, - ], - "value_decimal": -28406405723586.56, - }, - "event_type": Object { - "custom_event_name": "Q1Q49h4LP@", - "tracking_type": "Custom", - }, - "user": Object { - "aaid": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "data_processing_options": Object { - "country": "GM", - "modes": Array [ - "LDU", + "data": Object { + "events": Array [ + Object { + "action_source": "APP", + "click_id": "Q1Q49h4LP@", + "event_at": 1612137600000, + "event_metadata": Object { + "conversion_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "currency": "HNL", + "item_count": -2840640572358656, + "products": Array [ + Object { + "category": "Q1Q49h4LP@", + "id": "Q1Q49h4LP@", + "item_price": -28406405723586.56, + "name": "Q1Q49h4LP@", + "quantity": -2840640572358656, + }, ], - "region": "Q1Q49h4LP@", + "value": -28406405723586.56, }, - "email": "8614950525b98787b31febe07634987a801175f098b60c83bd3903bff1ec3eed", - "external_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "ip_address": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "phone_number": "781a379f97f79178f343a118a245e179b55a414b1b959ef25376dcb43bfe61f6", - "screen_dimensions": Object { - "height": -2840640572358656, - "width": -2840640572358656, + "event_source_url": "Q1Q49h4LP@", + "type": Object { + "custom_event_name": "Q1Q49h4LP@", + "tracking_type": "CUSTOM", + }, + "user": Object { + "aaid": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "data_processing_options": Object { + "country": "GM", + "modes": Array [ + "LDU", + ], + "region": "Q1Q49h4LP@", + }, + "email": "8614950525b98787b31febe07634987a801175f098b60c83bd3903bff1ec3eed", + "external_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "ip_address": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "phone_number": "781a379f97f79178f343a118a245e179b55a414b1b959ef25376dcb43bfe61f6", + "screen_dimensions": Object { + "height": -2840640572358656, + "width": -2840640572358656, + }, + "user_agent": "Q1Q49h4LP@", + "uuid": "Q1Q49h4LP@", }, - "user_agent": "Q1Q49h4LP@", - "uuid": "Q1Q49h4LP@", }, - }, - ], - "partner": "SEGMENT", - "test_mode": true, + ], + "partner": "SEGMENT", + "test_id": "Q1Q49h4LP@", + }, } `; @@ -84,7 +90,9 @@ Object { Object { "category": "gT6gXh8ga!%xhI", "id": "gT6gXh8ga!%xhI", + "item_price": 20910002898206.72, "name": "gT6gXh8ga!%xhI", + "quantity": 2091000289820672, }, ], "value_decimal": 20910002898206.72, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap index a1d96e2f9be..77ac5376abe 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -14,7 +14,9 @@ Object { Object { "category": "1dc[GGJK6O%*76J4O6Mz", "id": "1dc[GGJK6O%*76J4O6Mz", + "item_price": 83613772949749.77, "name": "1dc[GGJK6O%*76J4O6Mz", + "quantity": 8361377294974976, }, ], "value_decimal": 83613772949749.77, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 8dfcd05f7c1..9c84297e8c9 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -14,7 +14,9 @@ Object { Object { "category": "a@UzPN)1pp@tLb)vQZ2s", "id": "a@UzPN)1pp@tLb)vQZ2s", + "item_price": 85011847928872.95, "name": "a@UzPN)1pp@tLb)vQZ2s", + "quantity": 8501184792887296, }, ], "value_decimal": 85011847928872.95, From 3e68c886b821cb950b9838bcbda332284ed9b1a4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 13:16:56 +0100 Subject: [PATCH 14/20] Add unit tests for Reddit CAPI v3 single-event action and utils-v3 helpers Exports the previously-private helper functions in v3/utils-v3.ts (no logic changes) so they can be unit-tested directly. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/v3-single-event.test.ts | 187 +++++++++++ .../__tests__/v3-utils.test.ts | 313 ++++++++++++++++++ .../reddit-conversions-api/v3/utils-v3.ts | 12 +- 3 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts new file mode 100644 index 00000000000..2c9fabe88d6 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts @@ -0,0 +1,187 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +describe('Reddit Conversions Api - V3 single event', () => { + it('should send a Purchase Standard event to v3 when api_version is v3 and action_source is set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { + click_id: 'click_id_1', + currency: 'USD', + quantity: 10, + revenue: 100, + uuid: 'uuid_1', + products: [{ product_id: 'product_id_1', category: 'category_1', name: 'name_1', quantity: 2, price: 25 }], + email: 'test@test.com' + }, + context: { + userAgent: 'test-user-agent', + ip: '111.111.111.111', + page: { url: 'https://example.com/checkout' } + } + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('standardEvent', { + event, + settings: { ...settings, test_id: 'test-123' }, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: 'WEBSITE' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: { + partner: 'SEGMENT', + test_id: 'test-123', + events: [ + { + action_source: 'WEBSITE', + event_source_url: 'https://example.com/checkout', + click_id: 'click_id_1', + event_at: 1704721970212, + type: { + tracking_type: 'PURCHASE' + }, + event_metadata: { + currency: 'USD', + item_count: 10, + value: 100, + products: [ + { + category: 'category_1', + id: 'product_id_1', + name: 'name_1', + quantity: 2, + item_price: 25 + } + ] + } + } + ] + } + }) + }) + + it('should route a Custom event to v3 with UPPER_SNAKE_CASE tracking_type', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Some Custom Event Name', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: {} + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('customEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + custom_event_name: 'Some Custom Event Name', + api_version: 'v3', + action_source: 'APP' + } + }) + + expect(responses.length).toBe(1) + const body = responses[0].options.json as { data: { events: Array<{ type: { tracking_type: string } }> } } + expect(body.data.events[0].type.tracking_type).toBe('CUSTOM') + }) + + it('should stay on v2 when api_version is not set (existing customers)', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + // api_version is genuinely absent from the mapping here - that's the real "existing customer, + // pre-dates this field" shape. A literal '' would fail the field's enum (['v3', 'v2.0']) + // validation before perform() ever runs, so it can't be used to exercise this scenario. + const responses = await testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should reject the mapping when api_version is v3 but action_source is not set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + await expect( + testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3' + } + }) + ).rejects.toThrow("The root value is missing the required field 'action_source'.") + }) + + it('should throw when a product is missing an id (fails Destination-side validation, not schema validation)', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { + revenue: 100, + products: [{ category: 'category_1', name: 'name_1' }] + } + }) + + await expect( + testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: 'WEBSITE' + } + }) + ).rejects.toThrow('products.id is required when sending to Reddit Conversions API v3') + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts new file mode 100644 index 00000000000..da0811f1c6f --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts @@ -0,0 +1,313 @@ +import crypto from 'crypto' +import nock from 'nock' +import { MultiStatusResponse } from '@segment/actions-core' +import createRequestClient from '../../../../../core/src/create-request-client' +import { + sendV3, + createRedditPayloadV3, + toEpochMs, + toV3TrackingType, + toActionSourceV3, + getProducts, + toProductIdV3, + getMetadata +} from '../v3/utils-v3' +import type { Settings } from '../generated-types' +import type { Payload as StandardEvent } from '../standardEvent/generated-types' + +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +// Matches smartHash(conversion_id, (value) => value.trim()) in ../v3/utils-v3.ts +const sha256 = (value: string) => crypto.createHash('sha256').update(value.trim()).digest('hex') + +function buildPayload(overrides: Partial = {}): StandardEvent { + return { + event_at: 1704721970212, + tracking_type: 'Purchase', + action_source: 'WEBSITE', + ...overrides + } +} + +describe('toEpochMs', () => { + it('passes through an epoch-ms number unchanged', () => { + expect(toEpochMs(1704721970212)).toBe(1704721970212) + }) + + it('passes through an epoch-ms numeric string unchanged', () => { + expect(toEpochMs('1704721970212')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with milliseconds into epoch ms', () => { + expect(toEpochMs('2024-01-08T13:52:50.212Z')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with no milliseconds into epoch ms', () => { + expect(toEpochMs('2024-01-08T13:52:50Z')).toBe(1704721970000) + }) + + it('parses an ISO 8601 timestamp with a positive UTC offset into epoch ms', () => { + expect(toEpochMs('2024-01-08T18:52:50.212+05:00')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with a negative UTC offset into epoch ms', () => { + expect(toEpochMs('2024-01-08T08:52:50.212-05:00')).toBe(1704721970212) + }) + + it('parses a date-only ISO 8601 string as midnight UTC', () => { + expect(toEpochMs('2024-01-08')).toBe(1704672000000) + }) + + it('throws when value is undefined', () => { + expect(() => toEpochMs(undefined)).toThrow('event_at is required') + }) + + it('throws when value is an empty string', () => { + expect(() => toEpochMs('')).toThrow('event_at is required') + }) + + it('throws when a numeric string is below the epoch-ms floor (looks like epoch seconds)', () => { + expect(() => toEpochMs('1704721970')).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: 1704721970' + ) + }) + + it('throws when a number is below the epoch-ms floor', () => { + expect(() => toEpochMs(1704721970)).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: 1704721970' + ) + }) + + it('throws when the value is an unparseable string', () => { + expect(() => toEpochMs('not-a-date')).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: not-a-date' + ) + }) +}) + +describe('toV3TrackingType', () => { + it('maps a v2 tracking_type to its v3 UPPER_SNAKE_CASE equivalent', () => { + expect(toV3TrackingType('Purchase')).toBe('PURCHASE') + expect(toV3TrackingType('PageVisit')).toBe('PAGE_VISIT') + expect(toV3TrackingType('Custom')).toBe('CUSTOM') + }) + + it('throws when tracking_type is undefined', () => { + expect(() => toV3TrackingType(undefined)).toThrow('tracking_type is required') + }) + + it('throws when tracking_type is not a supported value', () => { + expect(() => toV3TrackingType('NotARealTrackingType')).toThrow('Unsupported tracking_type: NotARealTrackingType') + }) +}) + +describe('toActionSourceV3', () => { + it('passes through a supported action_source', () => { + expect(toActionSourceV3('WEBSITE')).toBe('WEBSITE') + expect(toActionSourceV3('APP')).toBe('APP') + expect(toActionSourceV3('OTHER')).toBe('OTHER') + expect(toActionSourceV3('PHYSICAL_STORE')).toBe('PHYSICAL_STORE') + }) + + it('throws when action_source is undefined', () => { + expect(() => toActionSourceV3(undefined)).toThrow( + 'action_source is required when sending to Reddit Conversions API v3' + ) + }) + + it('throws when action_source is not a supported value', () => { + expect(() => toActionSourceV3('NOT_REAL')).toThrow('Unsupported action_source: NOT_REAL') + }) +}) + +describe('toProductIdV3', () => { + it('trims and passes through a valid id', () => { + expect(toProductIdV3(' product_id_1 ')).toBe('product_id_1') + }) + + it('throws when id is undefined', () => { + expect(() => toProductIdV3(undefined)).toThrow('products.id is required when sending to Reddit Conversions API v3') + }) + + it('throws when id is an empty string', () => { + expect(() => toProductIdV3('')).toThrow('products.id is required when sending to Reddit Conversions API v3') + }) +}) + +describe('getProducts', () => { + it('returns undefined when products is undefined', () => { + expect(getProducts(undefined)).toBeUndefined() + }) + + it('maps every product field, defaulting quantity/item_price through cleanNum', () => { + expect( + getProducts([ + { category: ' category_1 ', id: 'product_id_1', name: ' name_1 ', quantity: 2, item_price: 25 }, + { id: 'product_id_2' } + ]) + ).toEqual([ + { category: 'category_1', id: 'product_id_1', name: 'name_1', quantity: 2, item_price: 25 }, + { category: undefined, id: 'product_id_2', name: undefined, quantity: undefined, item_price: undefined } + ]) + }) + + it('throws when any product in the list is missing an id', () => { + expect(() => getProducts([{ id: 'product_id_1' }, { category: 'category_2' }])).toThrow( + 'products.id is required when sending to Reddit Conversions API v3' + ) + }) +}) + +describe('getMetadata', () => { + it('returns undefined when metadata, products, and conversion_id are all absent', () => { + expect(getMetadata(undefined, undefined, undefined)).toBeUndefined() + }) + + it('maps currency/item_count/value_decimal->value, and hashes conversion_id', () => { + const result = getMetadata({ currency: 'USD', item_count: 10, value_decimal: 100 }, undefined, 'msg-1') + expect(result?.currency).toBe('USD') + expect(result?.item_count).toBe(10) + expect(result?.value).toBe(100) + expect(result?.products).toBeUndefined() + // conversion_id is smartHash'd - assert the actual sha256 hex digest, not just its shape. + expect(result?.conversion_id).toBe(sha256('msg-1')) + }) + + it('is present (not undefined) when only products are provided', () => { + const result = getMetadata(undefined, [{ id: 'product_id_1' }], undefined) + expect(result).toEqual({ + currency: undefined, + item_count: undefined, + value: undefined, + products: [ + { category: undefined, id: 'product_id_1', name: undefined, quantity: undefined, item_price: undefined } + ], + conversion_id: undefined + }) + }) +}) + +describe('createRedditPayloadV3', () => { + it('builds a v3 event item for a single valid standardEvent payload and marks it success', () => { + const multiStatusResponse = new MultiStatusResponse() + const payload = buildPayload({ + event_at: '2024-01-08T13:52:50.212Z', + click_id: 'click_id_1', + event_source_url: 'https://example.com/checkout' + }) + + const result = createRedditPayloadV3([payload], settings, multiStatusResponse, false) + + expect(result).toEqual({ + data: { + partner: 'SEGMENT', + test_id: undefined, + events: [ + { + event_at: 1704721970212, + action_source: 'WEBSITE', + event_source_url: 'https://example.com/checkout', + click_id: 'click_id_1', + type: { tracking_type: 'PURCHASE', custom_event_name: undefined }, + event_metadata: undefined, + user: undefined + } + ] + } + }) + + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.getResponseAtIndex(0)).toMatchObject({ status: 200, body: { success: true } }) + }) + + it('routes settings.test_id onto the payload', () => { + const multiStatusResponse = new MultiStatusResponse() + const result = createRedditPayloadV3( + [buildPayload()], + { ...settings, test_id: 'test-123' }, + multiStatusResponse, + false + ) + expect(result.data.test_id).toBe('test-123') + }) + + it('throws for a single (non-batch) invalid payload instead of recording a MultiStatusResponse error', () => { + const multiStatusResponse = new MultiStatusResponse() + const payload = buildPayload({ action_source: undefined }) + + expect(() => createRedditPayloadV3([payload], settings, multiStatusResponse, false)).toThrow( + 'action_source is required when sending to Reddit Conversions API v3' + ) + expect(multiStatusResponse.length()).toBe(0) + }) + + it('for a batch, records a MultiStatusResponse error at the failing index and continues processing the rest', () => { + const multiStatusResponse = new MultiStatusResponse() + const payloads = [ + buildPayload(), + buildPayload({ action_source: undefined }), + buildPayload({ event_at: 1704721970212 }) + ] + + const result = createRedditPayloadV3(payloads, settings, multiStatusResponse, true) + + expect(result.data.events).toHaveLength(2) + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(multiStatusResponse.getResponseAtIndex(1)).toMatchObject({ + status: 400, + errormessage: 'action_source is required when sending to Reddit Conversions API v3' + }) + expect(multiStatusResponse.isSuccessResponseAtIndex(2)).toBe(true) + }) +}) + +describe('sendV3', () => { + const request = createRequestClient() + + afterEach(() => { + nock.cleanAll() + }) + + it('POSTs to the v3 conversion_events endpoint and returns the raw response for a single (non-batch) event', async () => { + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, { + ok: true + }) + + const response = await sendV3(request, settings, [buildPayload()], false) + expect((response as { status: number }).status).toBe(200) + }) + + it('returns a MultiStatusResponse for a batch, and does not make an HTTP call when every payload fails validation', async () => { + const scope = nock('https://ads-api.reddit.com') + .post('/api/v3/pixels/ad_account_id_1/conversion_events') + .reply(200, {}) + + const payloads = [buildPayload({ action_source: undefined }), buildPayload({ action_source: undefined })] + const response = await sendV3(request, settings, payloads, true) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multiStatusResponse = response as MultiStatusResponse + expect(multiStatusResponse.isErrorResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(scope.isDone()).toBe(false) + }) + + it('returns a MultiStatusResponse for a batch that has at least one valid payload, and does make an HTTP call', async () => { + const scope = nock('https://ads-api.reddit.com') + .post('/api/v3/pixels/ad_account_id_1/conversion_events') + .reply(200, {}) + + const payloads = [buildPayload(), buildPayload({ action_source: undefined })] + const response = await sendV3(request, settings, payloads, true) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multiStatusResponse = response as MultiStatusResponse + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index b0f3c929acc..49a17e9dfbd 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -38,7 +38,7 @@ export async function sendV3( return multiStatusResponse } -function createRedditPayloadV3( +export function createRedditPayloadV3( payloads: (StandardEvent | CustomEvent)[], settings: Settings, multiStatusResponse: MultiStatusResponse, @@ -117,14 +117,14 @@ export function toEpochMs(value: string | number | undefined): number { ) } -function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { +export function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { if (!tracking_type) throw new PayloadValidationError('tracking_type is required') const mapped = (TRACKING_TYPE_V3 as Record)[tracking_type] if (!mapped) throw new PayloadValidationError(`Unsupported tracking_type: ${tracking_type}`) return mapped } -function toActionSourceV3(action_source: string | undefined): ActionSourceV3 { +export function toActionSourceV3(action_source: string | undefined): ActionSourceV3 { if (!action_source) throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') if (!(action_source in ACTION_SOURCE_V3_LABELS)) { @@ -133,7 +133,7 @@ function toActionSourceV3(action_source: string | undefined): ActionSourceV3 { return action_source as ActionSourceV3 } -function getProducts(products: ProductsType): ProductV3[] | undefined { +export function getProducts(products: ProductsType): ProductV3[] | undefined { if (!products) return undefined return products.map((product) => ({ category: clean(product.category), @@ -144,13 +144,13 @@ function getProducts(products: ProductsType): ProductV3[] | undefined { })) } -function toProductIdV3(id: string | undefined): string { +export function toProductIdV3(id: string | undefined): string { const cleaned = clean(id) if (!cleaned) throw new PayloadValidationError('products.id is required when sending to Reddit Conversions API v3') return cleaned } -function getMetadata( +export function getMetadata( metadata: EventMetadataType, products: ProductsType, conversion_id: ConversionIdType From d2a97d8f93311f37cf065b4118df1f5346126019 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 13:23:58 +0100 Subject: [PATCH 15/20] Add batch unit tests for Reddit CAPI v3 Co-Authored-By: Claude Sonnet 5 --- .../__tests__/v3-batch-events.test.ts | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts new file mode 100644 index 00000000000..61063179e45 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts @@ -0,0 +1,153 @@ +import crypto from 'crypto' +import nock from 'nock' +import { createTestEvent, createTestIntegration, JSONObject, SegmentEvent } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' +import { LEGACY_API_VERSION } from '../versioning-info' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const epochMs = 1704721970212 +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +// Matches processHashing('sha256', 'hex', (value) => value.trim()) in ../utils.ts +const sha256 = (value: string) => crypto.createHash('sha256').update(value.trim()).digest('hex') + +describe('Reddit Conversions Api - V3 batch events', () => { + it('handles a batch of 10: 2 fail schema validation, 2 fail Reddit-side validation inside performBatch, 6 succeed', async () => { + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + + // Interleaved on purpose so the 6 successes aren't all bunched at one end of the batch - + // 'schemaInvalid' fails Segment's own schema validation (action_source is conditionally required + // when api_version is v3) and never reaches performBatch; 'businessInvalid' passes schema (has + // action_source) but is missing products.id, which is only enforced inside performBatch by + // toProductIdV3; 'valid' should succeed end to end. + const kinds = [ + 'valid', + 'schemaInvalid', + 'valid', + 'businessInvalid', + 'valid', + 'schemaInvalid', + 'valid', + 'businessInvalid', + 'valid', + 'valid' + ] as const + + const events: SegmentEvent[] = kinds.map((kind, i) => { + const properties: JSONObject = { revenue: 100 } + if (kind !== 'schemaInvalid') properties.action_source = 'WEBSITE' + properties.products = + kind === 'businessInvalid' + ? [{ category: `c${i}`, name: `n${i}` }] + : [{ product_id: `p${i}`, category: `c${i}`, name: `n${i}` }] + + return createTestEvent({ + timestamp, + event: 'Order Completed', + messageId: `msg-${i}`, + type: 'track', + userId: `user_id_${i}`, + properties + }) + }) + + await testDestination.testBatchAction('standardEvent', { + events, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: { '@path': '$.properties.action_source' } + } + }) + + const multistatus = testDestination.results.at(0)?.multistatus + expect(multistatus).toHaveLength(10) + + kinds.forEach((kind, i) => { + if (kind === 'schemaInvalid') { + // Schema validation failure (Segment core, before performBatch is ever called). + expect(multistatus?.[i]).toEqual({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'action_source'.", + errorreporter: 'INTEGRATIONS' + }) + } else if (kind === 'businessInvalid') { + // Reddit-side validation failure (our own code, inside performBatch). + expect(multistatus?.[i]).toEqual({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'products.id is required when sending to Reddit Conversions API v3', + errorreporter: 'INTEGRATIONS' + }) + } else { + expect(multistatus?.[i]).toEqual({ + status: 200, + sent: { + event_at: epochMs, + action_source: 'WEBSITE', + type: { + tracking_type: 'PURCHASE' + }, + event_metadata: { + value: 100, + products: [ + { + category: `c${i}`, + id: `p${i}`, + name: `n${i}` + } + ], + conversion_id: sha256(`msg-${i}`) + }, + user: { + external_id: sha256(`user_id_${i}`) + } + }, + body: { success: true } + }) + } + }) + }) + + it('returns a plain response (no MultiStatusResponse) for a pure V2 batch', async () => { + nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + + const events: SegmentEvent[] = [0, 1, 2].map((i) => + createTestEvent({ + timestamp, + event: 'Order Completed', + messageId: `msg-v2-${i}`, + type: 'track', + userId: `user_id_${i}`, + properties: { revenue: 100 } + }) + ) + + await testDestination.testBatchAction('standardEvent', { + events, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: LEGACY_API_VERSION + } + }) + + // A pure-V2 batch never builds our own MultiStatusResponse - performBatch just returns the + // plain send() response, so core falls back to its own legacy "whole batch response" handling + // (fillMultiStatusResponse), marking every index success with the same status/body. + const multistatus = testDestination.results.at(0)?.multistatus + expect(multistatus).toHaveLength(3) + multistatus?.forEach((entry) => { + expect(entry).toMatchObject({ status: 200, body: {} }) + }) + }) +}) From 3e6f685c4f4808b4970e5da8341a34a7d2ffb28b Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 13:33:47 +0100 Subject: [PATCH 16/20] updating metadata --- .../reddit-conversions-api/metadata.json | 468 +++++++++++++++++- 1 file changed, 466 insertions(+), 2 deletions(-) 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 8afeab57cb1..e205820e25e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -27,14 +27,24 @@ "depends_on": null }, "test_mode": { - "label": "[Deprecated] Test Mode", - "description": "Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2, which is deprecated - V3 is the latest API version. To send test events on V3, set the Test ID field on an action mapping instead.", + "label": "Test Mode", + "description": "Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead.", "type": "boolean", "required": false, "multiple": false, "choices": null, "default": false, "depends_on": null + }, + "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 (Beta).", + "type": "string", + "required": false, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null } } }, @@ -190,6 +200,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -268,6 +284,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 (Beta).", + "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 (Beta).", + "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 +2602,139 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "api_version": { + "label": "API Version", + "description": "The version of the Reddit Conversions API to send this event to. \"V3 (Beta)\" requires Action Source to be set.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "v2.0", + "choices": [ + { + "label": "V3 (Beta)", + "value": "v3" + }, + { + "label": "V2", + "value": "v2.0" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": [ + "literal", + "variable", + "function", + "freeform", + "enrichment" + ], + "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, and required for, Reddit Conversions API V3 (Beta).", + "type": "string", + "required": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Other", + "value": "OTHER" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "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 (Beta).", + "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": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -2658,6 +2855,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -2736,6 +2939,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 (Beta).", + "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 (Beta).", + "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 +5257,139 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "api_version": { + "label": "API Version", + "description": "The version of the Reddit Conversions API to send this event to. \"V3 (Beta)\" requires Action Source to be set.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "v2.0", + "choices": [ + { + "label": "V3 (Beta)", + "value": "v3" + }, + { + "label": "V2", + "value": "v2.0" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": [ + "literal", + "variable", + "function", + "freeform", + "enrichment" + ], + "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, and required for, Reddit Conversions API V3 (Beta).", + "type": "string", + "required": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Other", + "value": "OTHER" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "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 (Beta).", + "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": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -5065,6 +5449,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5147,6 +5537,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "PageVisit" }, "eventSlug": null @@ -5185,6 +5579,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5267,6 +5667,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "ViewContent" }, "eventSlug": null @@ -5305,6 +5709,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5387,6 +5797,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Search" }, "eventSlug": null @@ -5425,6 +5839,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5517,6 +5937,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToCart" }, "eventSlug": null @@ -5555,6 +5979,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5647,6 +6077,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToWishlist" }, "eventSlug": null @@ -5685,6 +6119,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5787,6 +6227,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Purchase" }, "eventSlug": null @@ -5825,6 +6269,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5914,6 +6364,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Lead" }, "eventSlug": null @@ -5952,6 +6406,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -6041,6 +6501,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "SignUp" }, "eventSlug": null From 989e5789417cc9ecd1ece5a3d68595fd9300c166 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 19 Aug 2026 13:50:39 +0100 Subject: [PATCH 17/20] test fixes --- .../__tests__/v3-batch-events.test.ts | 9 +++++++-- .../reddit-conversions-api/__tests__/v3-utils.test.ts | 8 +++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts index 61063179e45..25f579a5339 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts @@ -76,7 +76,8 @@ describe('Reddit Conversions Api - V3 batch events', () => { expect(multistatus?.[i]).toEqual({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', - errormessage: "The root value is missing the required field 'action_source'.", + errormessage: + 'The root value is missing the required field \'action_source\'. The root value must match "then" schema.', errorreporter: 'INTEGRATIONS' }) } else if (kind === 'businessInvalid') { @@ -93,6 +94,7 @@ describe('Reddit Conversions Api - V3 batch events', () => { sent: { event_at: epochMs, action_source: 'WEBSITE', + event_source_url: 'https://segment.com/academy/', type: { tracking_type: 'PURCHASE' }, @@ -108,7 +110,10 @@ describe('Reddit Conversions Api - V3 batch events', () => { conversion_id: sha256(`msg-${i}`) }, user: { - external_id: sha256(`user_id_${i}`) + external_id: sha256(`user_id_${i}`), + ip_address: sha256('8.8.8.8'), + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' } }, body: { success: true } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts index da0811f1c6f..918500ce4d6 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts @@ -220,7 +220,7 @@ describe('createRedditPayloadV3', () => { }) expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) - expect(multiStatusResponse.getResponseAtIndex(0)).toMatchObject({ status: 200, body: { success: true } }) + expect(multiStatusResponse.getResponseAtIndex(0)).toMatchObject({ data: { status: 200, body: { success: true } } }) }) it('routes settings.test_id onto the payload', () => { @@ -258,8 +258,10 @@ describe('createRedditPayloadV3', () => { expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) expect(multiStatusResponse.getResponseAtIndex(1)).toMatchObject({ - status: 400, - errormessage: 'action_source is required when sending to Reddit Conversions API v3' + data: { + status: 400, + errormessage: 'action_source is required when sending to Reddit Conversions API v3' + } }) expect(multiStatusResponse.isSuccessResponseAtIndex(2)).toBe(true) }) From 841ee985a40d7e0a2bcea00959e0d1ba27f8792d Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 20 Aug 2026 13:19:13 +0100 Subject: [PATCH 18/20] fixing event_metadata bug --- .../__tests__/__snapshots__/snapshot.test.ts.snap | 4 ++-- .../reddit-conversions-api/__tests__/v3-batch-events.test.ts | 2 +- .../reddit-conversions-api/__tests__/v3-single-event.test.ts | 2 +- .../reddit-conversions-api/__tests__/v3-utils.test.ts | 2 +- .../src/destinations/reddit-conversions-api/v3/types-v3.ts | 2 +- .../src/destinations/reddit-conversions-api/v3/utils-v3.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap index f3f3ddfef9a..66bf43c8165 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -8,7 +8,8 @@ Object { "action_source": "APP", "click_id": "Q1Q49h4LP@", "event_at": 1612137600000, - "event_metadata": Object { + "event_source_url": "Q1Q49h4LP@", + "metadata": Object { "conversion_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", "currency": "HNL", "item_count": -2840640572358656, @@ -23,7 +24,6 @@ Object { ], "value": -28406405723586.56, }, - "event_source_url": "Q1Q49h4LP@", "type": Object { "custom_event_name": "Q1Q49h4LP@", "tracking_type": "CUSTOM", diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts index 25f579a5339..d27cff8878f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts @@ -98,7 +98,7 @@ describe('Reddit Conversions Api - V3 batch events', () => { type: { tracking_type: 'PURCHASE' }, - event_metadata: { + metadata: { value: 100, products: [ { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts index 2c9fabe88d6..a0cc4eacd27 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts @@ -61,7 +61,7 @@ describe('Reddit Conversions Api - V3 single event', () => { type: { tracking_type: 'PURCHASE' }, - event_metadata: { + metadata: { currency: 'USD', item_count: 10, value: 100, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts index 918500ce4d6..96f349a05b2 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts @@ -212,7 +212,7 @@ describe('createRedditPayloadV3', () => { event_source_url: 'https://example.com/checkout', click_id: 'click_id_1', type: { tracking_type: 'PURCHASE', custom_event_name: undefined }, - event_metadata: undefined, + metadata: undefined, user: undefined } ] diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts index 1d0e0d7c94c..c03208767ba 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts @@ -50,7 +50,7 @@ export interface EventItemV3 { tracking_type: EventTypeV3 custom_event_name?: string // required if tracking_type is CUSTOM } - event_metadata?: MetadataV3 + metadata?: MetadataV3 user?: UserV3 } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 49a17e9dfbd..855a5e63ac1 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -74,7 +74,7 @@ export function createRedditPayloadV3( tracking_type: toV3TrackingType(tracking_type), custom_event_name }, - event_metadata: getMetadata(event_metadata, products, conversion_id), + metadata: getMetadata(event_metadata, products, conversion_id), user: getUser(user, data_processing_options, screen_dimensions) } From 41b33446f71ebb69184255bbc12f2f356155dfb1 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Thu, 20 Aug 2026 17:34:11 +0100 Subject: [PATCH 19/20] fixing pre-existing mapping bug --- .../src/destinations/reddit-conversions-api/index.ts | 8 ++++---- .../src/destinations/reddit-conversions-api/metadata.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) 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 1a2a6da7220..f0c027ce44f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -148,8 +148,8 @@ const destination: DestinationDefinition = { tracking_type: 'AddToCart', event_metadata: { currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } + item_count: { '@path': '$.properties.quantity' }, + value_decimal: { '@path': '$.properties.price' } } }, type: 'automatic' @@ -164,8 +164,8 @@ const destination: DestinationDefinition = { tracking_type: 'AddToWishlist', event_metadata: { currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } + item_count: { '@path': '$.properties.quantity' }, + value_decimal: { '@path': '$.properties.price' } } }, type: 'automatic' 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 e205820e25e..ecaed7838bb 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -5927,10 +5927,10 @@ "currency": { "@path": "$.properties.currency" }, - "itemCount": { + "item_count": { "@path": "$.properties.quantity" }, - "value": { + "value_decimal": { "@path": "$.properties.price" } }, @@ -6067,10 +6067,10 @@ "currency": { "@path": "$.properties.currency" }, - "itemCount": { + "item_count": { "@path": "$.properties.quantity" }, - "value": { + "value_decimal": { "@path": "$.properties.price" } }, From 1dfd6b19dfb15f09d7f22eec2e88f2453a171ece Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Fri, 21 Aug 2026 12:05:38 +0100 Subject: [PATCH 20/20] STRATCONN-6892: filter event_metadata by tracking_type per Reddit CAPI docs PageVisit/ViewContent/Search do not support currency/value/item_count at all; Lead/SignUp support currency/value but not item_count. Reddit staging diagnostics flagged both cases ("unsupported metadata" for Search/View Content). Adds supportsValueMetadata/supportsItemCount helpers shared by both the V2 and V3 wire-payload builders, plus unit tests for each. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/utils.test.ts | 17 ++++++++++ .../__tests__/v3-utils.test.ts | 14 ++++++++ .../__snapshots__/snapshot.test.ts.snap | 1 - .../reddit-conversions-api/utils.ts | 33 +++++++++++++++---- .../reddit-conversions-api/v3/utils-v3.ts | 16 +++++---- 5 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts new file mode 100644 index 00000000000..99e0ff15903 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts @@ -0,0 +1,17 @@ +import { getMetadata } from '../utils' + +describe('getMetadata', () => { + it('drops currency/value_decimal/item_count for tracking types that don\'t support any event metadata', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Search') + expect(result?.currency).toBeUndefined() + expect(result?.item_count).toBeUndefined() + expect(result?.value_decimal).toBeUndefined() + }) + + it('drops item_count but keeps currency/value_decimal for Lead/SignUp', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Lead') + expect(result?.currency).toBe('USD') + expect(result?.value_decimal).toBe(10) + expect(result?.item_count).toBeUndefined() + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts index 96f349a05b2..39446702776 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts @@ -188,6 +188,20 @@ describe('getMetadata', () => { conversion_id: undefined }) }) + + it('drops currency/value/item_count for tracking types that don\'t support any event metadata', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Search') + expect(result?.currency).toBeUndefined() + expect(result?.item_count).toBeUndefined() + expect(result?.value).toBeUndefined() + }) + + it('drops item_count but keeps currency/value for Lead/SignUp', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Lead') + expect(result?.currency).toBe('USD') + expect(result?.value).toBe(10) + expect(result?.item_count).toBeUndefined() + }) }) describe('createRedditPayloadV3', () => { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 9c84297e8c9..caf7c196c97 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -9,7 +9,6 @@ Object { "event_metadata": Object { "conversion_id": "7da4d4522d67f6ca1c1b0654c2d91c916a22667a5d34fa75c7cdcbe0c1b452ce", "currency": "XPF", - "item_count": 8501184792887296, "products": Array [ Object { "category": "a@UzPN)1pp@tLb)vQZ2s", 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 a59d55bf4ea..031909a67d0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -46,17 +46,18 @@ function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings const custom_event_name = (payload as CustomEvent).custom_event_name const tracking_type = (payload as StandardEvent).tracking_type + const resolvedTrackingType = custom_event_name ? 'Custom' : 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, + tracking_type: resolvedTrackingType, custom_event_name: clean(custom_event_name) }, click_id: clean(click_id), - event_metadata: getMetadata(event_metadata, products, conversion_id), + event_metadata: getMetadata(event_metadata, products, conversion_id, resolvedTrackingType), user: getUser(user, data_processing_options, screen_dimensions) } @@ -80,6 +81,20 @@ export function cleanNum(num: number | undefined): number | undefined { return num } +// Per https://business.reddithelp.com/s/article/about-event-metadata: PageVisit/ViewContent/Search +// don't support currency/value/item_count at all (conversion_id/products are still fine), and +// Lead/SignUp support currency/value but not item_count. +const TRACKING_TYPES_WITHOUT_VALUE_METADATA = new Set(['PageVisit', 'ViewContent', 'Search']) +const TRACKING_TYPES_WITHOUT_ITEM_COUNT = new Set(['Lead', 'SignUp']) + +export function supportsValueMetadata(trackingType: string | undefined): boolean { + return !TRACKING_TYPES_WITHOUT_VALUE_METADATA.has(trackingType ?? '') +} + +export function supportsItemCount(trackingType: string | undefined): boolean { + return supportsValueMetadata(trackingType) && !TRACKING_TYPES_WITHOUT_ITEM_COUNT.has(trackingType ?? '') +} + function getProducts(products: ProductsType): Product[] | undefined { if (!products) { return undefined @@ -96,19 +111,23 @@ function getProducts(products: ProductsType): Product[] | undefined { }) } -function getMetadata( +export function getMetadata( metadata: EventMetadataType, products: ProductsType, - conversion_id: ConversionIdType + conversion_id: ConversionIdType, + trackingType?: string ): EventMetadata | undefined { if (!metadata && !products && !conversion_id) { return undefined } + const valueMetadataSupported = supportsValueMetadata(trackingType) + const itemCountSupported = supportsItemCount(trackingType) + return { - currency: clean(metadata?.currency), - item_count: cleanNum(metadata?.item_count), - value_decimal: cleanNum(metadata?.value_decimal), + currency: valueMetadataSupported ? clean(metadata?.currency) : undefined, + item_count: itemCountSupported ? cleanNum(metadata?.item_count) : undefined, + value_decimal: valueMetadataSupported ? cleanNum(metadata?.value_decimal) : undefined, products: getProducts(products), conversion_id: smartHash(conversion_id, (value) => value.trim()) } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts index 855a5e63ac1..2736e6c1ef8 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -5,7 +5,7 @@ import type { Payload as StandardEvent } from '../standardEvent/generated-types' import type { Payload as CustomEvent } from '../customEvent/generated-types' import { EventItemV3, PayloadV3, MetadataV3, ProductV3, ActionSourceV3, EventTypeV3 } from './types-v3' import { ACTION_SOURCE_V3_LABELS, TRACKING_TYPE_V3 } from './constants' -import { clean, cleanNum, getUser, smartHash } from '../utils' +import { clean, cleanNum, getUser, smartHash, supportsValueMetadata, supportsItemCount } from '../utils' import { LATEST_API_VERSION } from '../versioning-info' type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] @@ -74,7 +74,7 @@ export function createRedditPayloadV3( tracking_type: toV3TrackingType(tracking_type), custom_event_name }, - metadata: getMetadata(event_metadata, products, conversion_id), + metadata: getMetadata(event_metadata, products, conversion_id, tracking_type), user: getUser(user, data_processing_options, screen_dimensions) } @@ -153,15 +153,19 @@ export function toProductIdV3(id: string | undefined): string { export function getMetadata( metadata: EventMetadataType, products: ProductsType, - conversion_id: ConversionIdType + conversion_id: ConversionIdType, + trackingType?: string ): MetadataV3 | undefined { if (!metadata && !products && !conversion_id) return undefined + const valueMetadataSupported = supportsValueMetadata(trackingType) + const itemCountSupported = supportsItemCount(trackingType) + return { - currency: clean(metadata?.currency), - item_count: cleanNum(metadata?.item_count), + currency: valueMetadataSupported ? clean(metadata?.currency) : undefined, + item_count: itemCountSupported ? cleanNum(metadata?.item_count) : undefined, // The Segment-facing field is still named `value_decimal` (unchanged from v2, so existing // mappings keep working) - only the wire-level key sent to Reddit v3 renames to `value`. - value: cleanNum(metadata?.value_decimal), + value: valueMetadataSupported ? cleanNum(metadata?.value_decimal) : undefined, products: getProducts(products), conversion_id: smartHash(conversion_id, (value) => value.trim()) }