diff --git a/packages/destination-actions/src/destinations/topsort/click/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/topsort/click/__tests__/__snapshots__/snapshot.test.ts.snap index e125dfd8f6c..01d918863df 100644 --- a/packages/destination-actions/src/destinations/topsort/click/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/topsort/click/__tests__/__snapshots__/snapshot.test.ts.snap @@ -8,6 +8,9 @@ Object { "testType": "YPhGI%A", }, "channel": "YPhGI%A", + "dsp_metadata": Object { + "testType": "YPhGI%A", + }, "entity": Object { "id": "YPhGI%A", "type": "YPhGI%A", diff --git a/packages/destination-actions/src/destinations/topsort/click/__tests__/index.test.ts b/packages/destination-actions/src/destinations/topsort/click/__tests__/index.test.ts index 5536ebbfc2a..288232e93ea 100644 --- a/packages/destination-actions/src/destinations/topsort/click/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/topsort/click/__tests__/index.test.ts @@ -1,5 +1,4 @@ import nock from 'nock' -import { AggregateAjvError } from '@segment/ajv-human-errors' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import type { SegmentEvent } from '@segment/actions-core' import Destination from '../../index' @@ -69,20 +68,71 @@ describe('Topsort.click', () => { }) }) - it('should fail because it misses a required field (resolvedBidId)', async () => { + it('should be successful for an offsite click without resolvedBidId', async () => { nock(/.*/).persist().post(/.*/).reply(200) - const event = createTestEvent({}) + const event = createTestEvent({ + properties: { + externalVendorId: '1234', + externalCampaignId: 'campaignId1234', + entity: { id: '677061', type: 'product' }, + channel: 'offsite', + dspMetadata: { gclid: 'google-click-id-123' } + } + }) - await expect( - testDestination.testAction('click', { - event, - settings: { - api_key: 'bar' - }, - useDefaultMappings: true - }) - ).rejects.toThrowError(AggregateAjvError) + const responses = await testDestination.testAction('click', { + event, + settings: { + api_key: 'bar' + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const click = (responses[0].options.json as { clicks: Record[] }).clicks[0] + expect(click).not.toHaveProperty('resolvedBidId') + expect(click).not.toHaveProperty('dspMetadata') + expect(responses[0].options.json).toMatchObject({ + clicks: expect.arrayContaining([ + expect.objectContaining({ + externalVendorId: '1234', + externalCampaignId: 'campaignId1234', + entity: { id: '677061', type: 'product' }, + channel: 'offsite', + dsp_metadata: { gclid: 'google-click-id-123' } + }) + ]) + }) + }) + + it('should coerce non-string dspMetadata values to strings', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + resolvedBidId: 'thisisaresolvedbidid', + dspMetadata: { gclid: 'abc', score: 42, nested: { a: 1 } } + } + }) + + const responses = await testDestination.testAction('click', { + event, + settings: { + api_key: 'bar' + }, + useDefaultMappings: true + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + clicks: expect.arrayContaining([ + expect.objectContaining({ + dsp_metadata: { gclid: 'abc', score: '42', nested: '{"a":1}' } + }) + ]) + }) }) it('should be successful with new optional fields', async () => { diff --git a/packages/destination-actions/src/destinations/topsort/click/generated-types.ts b/packages/destination-actions/src/destinations/topsort/click/generated-types.ts index fe94fa41b49..06d0aed3a82 100644 --- a/packages/destination-actions/src/destinations/topsort/click/generated-types.ts +++ b/packages/destination-actions/src/destinations/topsort/click/generated-types.ts @@ -14,9 +14,9 @@ export interface Payload { */ opaqueUserId: string /** - * Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. + * Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. Required for onsite sponsored events; omit for offsite or organic events, which are attributed via entity, externalCampaignId/externalVendorId and channel instead. */ - resolvedBidId: string + resolvedBidId?: string /** * Additional attribution information. */ @@ -119,4 +119,10 @@ export interface Payload { * The channel where the event occurred. */ channel?: string + /** + * Metadata used to forward click identifiers to the DSP for offsite conversions (e.g. { "gclid": "..." } for Google, or the equivalent click identifier for Meta). The accepted keys depend on the advertising platform. Values must be strings (the API expects a map of string to string); non-string values are JSON-stringified before sending. Typically only set for offsite events. + */ + dspMetadata?: { + [k: string]: unknown + } } diff --git a/packages/destination-actions/src/destinations/topsort/click/index.ts b/packages/destination-actions/src/destinations/topsort/click/index.ts index 84f2cfb7426..2683145e082 100644 --- a/packages/destination-actions/src/destinations/topsort/click/index.ts +++ b/packages/destination-actions/src/destinations/topsort/click/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 { TopsortAPIClient } from '../client' -import { NormalizeDeviceType } from '../functions' +import { NormalizeDeviceType, NormalizeDspMetadata } from '../functions' const action: ActionDefinition = { title: 'Click', @@ -41,9 +41,9 @@ const action: ActionDefinition = { resolvedBidId: { label: 'Resolved Bid ID', description: - 'Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters.', + 'Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. Required for onsite sponsored events; omit for offsite or organic events, which are attributed via entity, externalCampaignId/externalVendorId and channel instead.', type: 'string', - required: true, + required: false, default: { '@path': '$.properties.resolvedBidId' } @@ -264,14 +264,29 @@ const action: ActionDefinition = { default: { '@path': '$.properties.channel' } + }, + dspMetadata: { + label: 'DSP Metadata', + description: + 'Metadata used to forward click identifiers to the DSP for offsite conversions (e.g. { "gclid": "..." } for Google, or the equivalent click identifier for Meta). The accepted keys depend on the advertising platform. Values must be strings (the API expects a map of string to string); non-string values are JSON-stringified before sending. Typically only set for offsite events.', + type: 'object', + required: false, + default: { + '@path': '$.properties.dspMetadata' + } } }, perform: (request, { payload, settings }) => { const client = new TopsortAPIClient(request, settings) - payload.deviceType = NormalizeDeviceType(payload.deviceType) + const { dspMetadata, ...rest } = payload + const click = { + ...rest, + deviceType: NormalizeDeviceType(payload.deviceType), + dsp_metadata: NormalizeDspMetadata(dspMetadata) + } return client.sendEvent({ - clicks: [payload] + clicks: [click] }) } } diff --git a/packages/destination-actions/src/destinations/topsort/functions.ts b/packages/destination-actions/src/destinations/topsort/functions.ts index fe3c54db65d..0b5b9eb909e 100644 --- a/packages/destination-actions/src/destinations/topsort/functions.ts +++ b/packages/destination-actions/src/destinations/topsort/functions.ts @@ -11,3 +11,21 @@ export function NormalizeDeviceType(t: string | undefined): string | undefined { } return t } + +// The Report Events API expects dsp_metadata to be a map of string to string. Coerce any non-string +// values to strings so a single number/object/array value doesn't cause the whole batch to be rejected. +export function NormalizeDspMetadata( + metadata: { [k: string]: unknown } | undefined +): { [k: string]: string } | undefined { + if (!metadata) { + return undefined + } + const normalized: { [k: string]: string } = {} + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined || value === null) { + continue + } + normalized[key] = typeof value === 'string' ? value : JSON.stringify(value) + } + return Object.keys(normalized).length > 0 ? normalized : undefined +} diff --git a/packages/destination-actions/src/destinations/topsort/impression/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/topsort/impression/__tests__/__snapshots__/snapshot.test.ts.snap index b2cb729f173..2d102dc224b 100644 --- a/packages/destination-actions/src/destinations/topsort/impression/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/topsort/impression/__tests__/__snapshots__/snapshot.test.ts.snap @@ -8,6 +8,9 @@ Object { "testType": "13(ufD", }, "channel": "13(ufD", + "dsp_metadata": Object { + "testType": "13(ufD", + }, "entity": Object { "id": "13(ufD", "type": "13(ufD", diff --git a/packages/destination-actions/src/destinations/topsort/impression/__tests__/index.test.ts b/packages/destination-actions/src/destinations/topsort/impression/__tests__/index.test.ts index 96b3d64b220..fede8aa90b5 100644 --- a/packages/destination-actions/src/destinations/topsort/impression/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/topsort/impression/__tests__/index.test.ts @@ -1,5 +1,4 @@ import nock from 'nock' -import { AggregateAjvError } from '@segment/ajv-human-errors' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import type { SegmentEvent } from '@segment/actions-core' import Destination from '../../index' @@ -232,20 +231,71 @@ describe('Topsort.impression', () => { }) }) - it('should fail because it misses a required field (resolvedBidId)', async () => { + it('should be successful for an offsite impression without resolvedBidId', async () => { nock(/.*/).persist().post(/.*/).reply(200) - const event = createTestEvent({}) + const event = createTestEvent({ + properties: { + externalVendorId: '1234', + externalCampaignId: 'campaignId1234', + entity: { id: '677061', type: 'product' }, + channel: 'offsite', + dspMetadata: { gclid: 'google-click-id-123' } + } + }) - await expect( - testDestination.testAction('impression', { - event, - settings: { - api_key: 'bar' - }, - useDefaultMappings: true - }) - ).rejects.toThrowError(AggregateAjvError) + const responses = await testDestination.testAction('impression', { + event, + settings: { + api_key: 'bar' + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const impression = (responses[0].options.json as { impressions: Record[] }).impressions[0] + expect(impression).not.toHaveProperty('resolvedBidId') + expect(impression).not.toHaveProperty('dspMetadata') + expect(responses[0].options.json).toMatchObject({ + impressions: expect.arrayContaining([ + expect.objectContaining({ + externalVendorId: '1234', + externalCampaignId: 'campaignId1234', + entity: { id: '677061', type: 'product' }, + channel: 'offsite', + dsp_metadata: { gclid: 'google-click-id-123' } + }) + ]) + }) + }) + + it('should coerce non-string dspMetadata values to strings', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + resolvedBidId: 'thisisaresolvedbidid', + dspMetadata: { gclid: 'abc', score: 42, nested: { a: 1 } } + } + }) + + const responses = await testDestination.testAction('impression', { + event, + settings: { + api_key: 'bar' + }, + useDefaultMappings: true + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + impressions: expect.arrayContaining([ + expect.objectContaining({ + dsp_metadata: { gclid: 'abc', score: '42', nested: '{"a":1}' } + }) + ]) + }) }) }) diff --git a/packages/destination-actions/src/destinations/topsort/impression/generated-types.ts b/packages/destination-actions/src/destinations/topsort/impression/generated-types.ts index fe94fa41b49..06d0aed3a82 100644 --- a/packages/destination-actions/src/destinations/topsort/impression/generated-types.ts +++ b/packages/destination-actions/src/destinations/topsort/impression/generated-types.ts @@ -14,9 +14,9 @@ export interface Payload { */ opaqueUserId: string /** - * Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. + * Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. Required for onsite sponsored events; omit for offsite or organic events, which are attributed via entity, externalCampaignId/externalVendorId and channel instead. */ - resolvedBidId: string + resolvedBidId?: string /** * Additional attribution information. */ @@ -119,4 +119,10 @@ export interface Payload { * The channel where the event occurred. */ channel?: string + /** + * Metadata used to forward click identifiers to the DSP for offsite conversions (e.g. { "gclid": "..." } for Google, or the equivalent click identifier for Meta). The accepted keys depend on the advertising platform. Values must be strings (the API expects a map of string to string); non-string values are JSON-stringified before sending. Typically only set for offsite events. + */ + dspMetadata?: { + [k: string]: unknown + } } diff --git a/packages/destination-actions/src/destinations/topsort/impression/index.ts b/packages/destination-actions/src/destinations/topsort/impression/index.ts index 55771e256b5..afbb1ae79ac 100644 --- a/packages/destination-actions/src/destinations/topsort/impression/index.ts +++ b/packages/destination-actions/src/destinations/topsort/impression/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 { TopsortAPIClient } from '../client' -import { NormalizeDeviceType } from '../functions' +import { NormalizeDeviceType, NormalizeDspMetadata } from '../functions' const action: ActionDefinition = { title: 'Impression', @@ -41,9 +41,9 @@ const action: ActionDefinition = { resolvedBidId: { label: 'Resolved Bid ID', description: - 'Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters.', + 'Identifier of an instance of a resolved auction for a determined product. The length should not exceed 128 characters. Required for onsite sponsored events; omit for offsite or organic events, which are attributed via entity, externalCampaignId/externalVendorId and channel instead.', type: 'string', - required: true, + required: false, default: { '@path': '$.properties.resolvedBidId' } @@ -264,14 +264,29 @@ const action: ActionDefinition = { default: { '@path': '$.properties.channel' } + }, + dspMetadata: { + label: 'DSP Metadata', + description: + 'Metadata used to forward click identifiers to the DSP for offsite conversions (e.g. { "gclid": "..." } for Google, or the equivalent click identifier for Meta). The accepted keys depend on the advertising platform. Values must be strings (the API expects a map of string to string); non-string values are JSON-stringified before sending. Typically only set for offsite events.', + type: 'object', + required: false, + default: { + '@path': '$.properties.dspMetadata' + } } }, perform: (request, { payload, settings }) => { const client = new TopsortAPIClient(request, settings) - payload.deviceType = NormalizeDeviceType(payload.deviceType) + const { dspMetadata, ...rest } = payload + const impression = { + ...rest, + deviceType: NormalizeDeviceType(payload.deviceType), + dsp_metadata: NormalizeDspMetadata(dspMetadata) + } return client.sendEvent({ - impressions: [payload] + impressions: [impression] }) } } diff --git a/packages/destination-actions/src/destinations/topsort/pageviews/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/topsort/pageviews/__tests__/__snapshots__/snapshot.test.ts.snap index b2cb729f173..2d102dc224b 100644 --- a/packages/destination-actions/src/destinations/topsort/pageviews/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/topsort/pageviews/__tests__/__snapshots__/snapshot.test.ts.snap @@ -8,6 +8,9 @@ Object { "testType": "13(ufD", }, "channel": "13(ufD", + "dsp_metadata": Object { + "testType": "13(ufD", + }, "entity": Object { "id": "13(ufD", "type": "13(ufD",