Skip to content

Commit 6128951

Browse files
committed
Add multistatus in linkedin conversions' stream conversion event
1 parent 0a0f89a commit 6128951

3 files changed

Lines changed: 268 additions & 38 deletions

File tree

packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {
33
ModifiedResponse,
44
DynamicFieldResponse,
55
ActionHookResponse,
6-
PayloadValidationError
6+
PayloadValidationError,
7+
JSONLikeObject,
8+
MultiStatusResponse,
9+
HTTPError,
10+
InvalidAuthenticationError,
11+
ErrorCodes
712
} from '@segment/actions-core'
813
import { BASE_URL, DEFAULT_POST_CLICK_LOOKBACK_WINDOW, DEFAULT_VIEW_THROUGH_LOOKBACK_WINDOW } from '../constants'
914
import type {
@@ -471,39 +476,95 @@ export class LinkedInConversions {
471476
})
472477
}
473478

474-
async batchConversionAdd(payloads: Payload[]): Promise<ModifiedResponse> {
475-
return this.request(`${BASE_URL}/conversionEvents`, {
476-
method: 'post',
477-
headers: {
478-
'X-RestLi-Method': 'BATCH_CREATE'
479-
},
480-
json: {
481-
elements: [
482-
...payloads.map((payload) => {
483-
const conversionTime = isNotEpochTimestampInMilliseconds(payload.conversionHappenedAt)
484-
? convertToEpochMillis(payload.conversionHappenedAt)
485-
: Number(payload.conversionHappenedAt)
486-
validate(payload, conversionTime)
487-
488-
const userIds = this.buildUserIdsArray(payload)
489-
return {
490-
conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}`,
491-
conversionHappenedAt: conversionTime,
492-
conversionValue: payload.conversionValue,
493-
eventId: payload.eventId,
494-
user: {
495-
userIds,
496-
userInfo: payload.userInfo,
497-
// only 1 externalId value allowed currently in the externalIds array by LinkedIn currently Oct 2025
498-
...(Array.isArray(payload?.externalIds) && payload.externalIds.length > 0
499-
? { externalIds: [payload.externalIds[0]] }
500-
: {})
501-
}
502-
}
503-
})
504-
]
479+
async batchConversionAdd(payloads: Payload[]): Promise<MultiStatusResponse> {
480+
const multiStatusResponse = new MultiStatusResponse()
481+
const validElements: ReturnType<typeof this.buildConversionElement>[] = []
482+
const validResponseIndices: number[] = []
483+
484+
payloads.forEach((payload, i) => {
485+
const conversionTime = isNotEpochTimestampInMilliseconds(payload.conversionHappenedAt)
486+
? convertToEpochMillis(payload.conversionHappenedAt)
487+
: Number(payload.conversionHappenedAt)
488+
489+
try {
490+
validate(payload, conversionTime)
491+
validElements.push(this.buildConversionElement(payload, conversionTime))
492+
validResponseIndices.push(i)
493+
} catch (e) {
494+
multiStatusResponse.setErrorResponseAtIndex(i, {
495+
status: 400,
496+
errortype: 'PAYLOAD_VALIDATION_FAILED',
497+
errormessage: (e as Error).message
498+
})
505499
}
506500
})
501+
502+
if (validElements.length === 0) {
503+
return multiStatusResponse
504+
}
505+
506+
try {
507+
const response = await this.request(`${BASE_URL}/conversionEvents`, {
508+
method: 'post',
509+
headers: {
510+
'X-RestLi-Method': 'BATCH_CREATE'
511+
},
512+
json: {
513+
elements: validElements
514+
}
515+
})
516+
517+
validResponseIndices.forEach((originalIndex, filteredIndex) => {
518+
multiStatusResponse.setSuccessResponseAtIndex(originalIndex, {
519+
status: 201,
520+
sent: validElements[filteredIndex] as unknown as JSONLikeObject,
521+
body: (response.content ?? response.data ?? '') as unknown as JSONLikeObject
522+
})
523+
})
524+
} catch (error) {
525+
if (error instanceof HTTPError) {
526+
const status = error.response.status
527+
528+
// 401 means the OAuth token is expired — re-throw so the platform triggers a token refresh
529+
if (status === 401) {
530+
throw new InvalidAuthenticationError(
531+
'LinkedIn OAuth token is expired or invalid. Please re-authenticate.',
532+
ErrorCodes.INVALID_AUTHENTICATION
533+
)
534+
}
535+
536+
// For all other API errors, mark every valid event as failed with the actual status
537+
validResponseIndices.forEach((originalIndex, filteredIndex) => {
538+
multiStatusResponse.setErrorResponseAtIndex(originalIndex, {
539+
status,
540+
errormessage: error.message,
541+
sent: validElements[filteredIndex] as unknown as JSONLikeObject
542+
})
543+
})
544+
} else {
545+
throw error
546+
}
547+
}
548+
549+
return multiStatusResponse
550+
}
551+
552+
private buildConversionElement(payload: Payload, conversionTime: number) {
553+
const userIds = this.buildUserIdsArray(payload)
554+
return {
555+
conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}`,
556+
conversionHappenedAt: conversionTime,
557+
conversionValue: payload.conversionValue,
558+
eventId: payload.eventId,
559+
user: {
560+
userIds,
561+
userInfo: payload.userInfo,
562+
// only 1 externalId value allowed currently in the externalIds array by LinkedIn currently Oct 2025
563+
...(Array.isArray(payload?.externalIds) && payload.externalIds.length > 0
564+
? { externalIds: [payload.externalIds[0]] }
565+
: {})
566+
}
567+
}
507568
}
508569

509570
async bulkAssociateCampaignToConversion(campaignIds?: string[]): Promise<ModifiedResponse | void> {

packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,3 +1211,177 @@ describe('LinkedinConversions.onMappingSave - performHook', () => {
12111211
})
12121212
})
12131213
})
1214+
1215+
describe('LinkedinConversions.multistatus', () => {
1216+
const staleTimestamp = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString()
1217+
1218+
const staleEvent = createTestEvent({
1219+
messageId: 'stale-event-id',
1220+
event: 'Funding Application Submitted',
1221+
type: 'track',
1222+
timestamp: staleTimestamp,
1223+
context: { traits: { email: '[email protected]' } },
1224+
properties: {}
1225+
})
1226+
1227+
const batchMapping = {
1228+
email: { '@path': '$.context.traits.email' },
1229+
conversionHappenedAt: { '@path': '$.timestamp' },
1230+
onMappingSave: {
1231+
inputs: {},
1232+
outputs: { id: payload.conversionId }
1233+
},
1234+
enable_batching: true,
1235+
batch_size: 5000
1236+
}
1237+
1238+
it('should mark stale event as PAYLOAD_VALIDATION_FAILED and still send valid events', async () => {
1239+
nock(`${BASE_URL}/conversionEvents`)
1240+
.post('', {
1241+
elements: [
1242+
{
1243+
conversion: `urn:lla:llaPartnerConversion:${payload.conversionId}`,
1244+
conversionHappenedAt: currentTimestamp,
1245+
user: {
1246+
userIds: [
1247+
{ idType: 'SHA256_EMAIL', idValue: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777' }
1248+
]
1249+
}
1250+
}
1251+
]
1252+
})
1253+
.reply(201)
1254+
1255+
const response = await testDestination.executeBatch('streamConversion', {
1256+
events: [event, staleEvent],
1257+
settings,
1258+
mapping: batchMapping
1259+
})
1260+
1261+
// valid event succeeds
1262+
expect(response[0]).toMatchObject({ status: 201 })
1263+
// stale event fails with PAYLOAD_VALIDATION_FAILED
1264+
expect(response[1]).toMatchObject({
1265+
status: 400,
1266+
errortype: 'PAYLOAD_VALIDATION_FAILED',
1267+
errormessage: 'Timestamp should be within the past 90 days.',
1268+
errorreporter: 'INTEGRATIONS'
1269+
})
1270+
})
1271+
1272+
it('should not make an API call when all events in the batch are invalid', async () => {
1273+
const staleEvent2 = createTestEvent({
1274+
messageId: 'stale-event-id-2',
1275+
event: 'Funding Application Submitted',
1276+
type: 'track',
1277+
timestamp: staleTimestamp,
1278+
context: { traits: { email: '[email protected]' } },
1279+
properties: {}
1280+
})
1281+
1282+
const scope = nock(`${BASE_URL}/conversionEvents`).post('').reply(201)
1283+
1284+
const response = await testDestination.executeBatch('streamConversion', {
1285+
events: [staleEvent, staleEvent2],
1286+
settings,
1287+
mapping: batchMapping
1288+
})
1289+
1290+
expect(scope.isDone()).toBe(false)
1291+
expect(response[0]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' })
1292+
expect(response[1]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' })
1293+
})
1294+
1295+
it('should mark event as PAYLOAD_VALIDATION_FAILED when user ID is missing', async () => {
1296+
const noUserIdEvent = createTestEvent({
1297+
messageId: 'no-user-id-event',
1298+
event: 'Funding Application Submitted',
1299+
type: 'track',
1300+
timestamp: currentTimestamp.toString(),
1301+
context: { traits: {} },
1302+
properties: {}
1303+
})
1304+
1305+
nock(`${BASE_URL}/conversionEvents`)
1306+
.post('', {
1307+
elements: [
1308+
{
1309+
conversion: `urn:lla:llaPartnerConversion:${payload.conversionId}`,
1310+
conversionHappenedAt: currentTimestamp,
1311+
user: {
1312+
userIds: [
1313+
{ idType: 'SHA256_EMAIL', idValue: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777' }
1314+
]
1315+
}
1316+
}
1317+
]
1318+
})
1319+
.reply(201)
1320+
1321+
const response = await testDestination.executeBatch('streamConversion', {
1322+
events: [event, noUserIdEvent],
1323+
settings,
1324+
mapping: batchMapping
1325+
})
1326+
1327+
expect(response[0]).toMatchObject({ status: 201 })
1328+
expect(response[1]).toMatchObject({
1329+
status: 400,
1330+
errortype: 'PAYLOAD_VALIDATION_FAILED',
1331+
errormessage: 'One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.',
1332+
errorreporter: 'INTEGRATIONS'
1333+
})
1334+
})
1335+
1336+
it('should correctly map responses to original batch indices', async () => {
1337+
nock(`${BASE_URL}/conversionEvents`).post('').reply(201)
1338+
1339+
const response = await testDestination.executeBatch('streamConversion', {
1340+
events: [staleEvent, event, staleEvent],
1341+
settings,
1342+
mapping: batchMapping
1343+
})
1344+
1345+
expect(response[0]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' })
1346+
expect(response[1]).toMatchObject({ status: 201 })
1347+
expect(response[2]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED' })
1348+
})
1349+
1350+
it('should throw InvalidAuthenticationError when LinkedIn returns 401', async () => {
1351+
nock(`${BASE_URL}/conversionEvents`).post('').reply(401, { message: 'Unauthorized' })
1352+
1353+
await expect(
1354+
testDestination.executeBatch('streamConversion', {
1355+
events: [event],
1356+
settings,
1357+
mapping: batchMapping
1358+
})
1359+
).rejects.toThrow('LinkedIn OAuth token is expired or invalid. Please re-authenticate.')
1360+
})
1361+
1362+
it('should mark all valid events as failed when LinkedIn returns 429', async () => {
1363+
nock(`${BASE_URL}/conversionEvents`).post('').reply(429, { message: 'Too Many Requests' })
1364+
1365+
const response = await testDestination.executeBatch('streamConversion', {
1366+
events: [event, secondEvent],
1367+
settings,
1368+
mapping: batchMapping
1369+
})
1370+
1371+
expect(response[0]).toMatchObject({ status: 429 })
1372+
expect(response[1]).toMatchObject({ status: 429 })
1373+
})
1374+
1375+
it('should mark all valid events as failed when LinkedIn returns 500', async () => {
1376+
nock(`${BASE_URL}/conversionEvents`).post('').reply(500, { message: 'Internal Server Error' })
1377+
1378+
const response = await testDestination.executeBatch('streamConversion', {
1379+
events: [event, secondEvent],
1380+
settings,
1381+
mapping: batchMapping
1382+
})
1383+
1384+
expect(response[0]).toMatchObject({ status: 500 })
1385+
expect(response[1]).toMatchObject({ status: 500 })
1386+
})
1387+
})

packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,12 +355,7 @@ const action: ActionDefinition<Settings, Payload, undefined, OnMappingSaveInputs
355355
}
356356

357357
linkedinApiClient.setConversionRuleId(conversionRuleId)
358-
359-
try {
360-
return linkedinApiClient.batchConversionAdd(payloads)
361-
} catch (error) {
362-
throw handleRequestError(error)
363-
}
358+
return linkedinApiClient.batchConversionAdd(payloads)
364359
}
365360
}
366361

0 commit comments

Comments
 (0)