From f68b911a0a4822f27cec2f223bcd96e12150e35c Mon Sep 17 00:00:00 2001 From: Sayan Das <109198085+sayan-das-in@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:18:57 +0530 Subject: [PATCH] [Braze] Add MultiStatus Response support (#2423) * Added multistatus support to braze with unit tests * Added multi-status response and unit tests for sync-mode * Improved error handling --- .../braze/__tests__/multistatus.test.ts | 903 ++++++++++++++++++ .../destinations/braze/trackEvent2/index.ts | 6 +- .../braze/trackPurchase2/index.ts | 6 +- .../braze/updateUserProfile2/index.ts | 6 +- .../src/destinations/braze/utils.ts | 363 +++++-- 5 files changed, 1188 insertions(+), 96 deletions(-) create mode 100644 packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts diff --git a/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts new file mode 100644 index 0000000000..8e31c63d86 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts @@ -0,0 +1,903 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Braze from '../index' +import { BrazeTrackUserAPIResponse } from '../utils' + +beforeEach(() => nock.cleanAll()) + +const testDestination = createTestIntegration(Braze) + +const settings = { + app_id: 'my-app-id', + api_key: 'my-api-key', + endpoint: 'https://rest.iad-01.braze.com' as const +} + +const receivedAt = '2024-08-01T17:40:04.055Z' + +describe('MultiStatus', () => { + describe('trackEvent', () => { + const mapping = { + name: { + '@path': '$.traits.name' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 2, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'events', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + // The first doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200 + }) + + // The second event fails as Braze API reports an error + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'a test error occurred', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('trackPurchase', () => { + const mapping = { + time: receivedAt, + external_id: { + '@path': '$.properties.externalId' + }, + products: { + '@path': '$.properties.products' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }), + // Event without any user identifier + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The second event fails as it doesn't have any products + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errorreporter: 'DESTINATION' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[2]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 4, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'purchases', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + // Since each product in an event is further flattened to multiple items in requests, + // if any one of the is reported as failed then the entire event is considered failed. + + // The first event fails as it expands to 2 request items and one of them fails + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'a test error occurred', + errorreporter: 'DESTINATION' + }) + + // The second event doesn't fail as both the expanded request items are successful + expect(response[1]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as it doesn't have any products and skipped with success response + expect(response[2]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('updateUserProfile', () => { + const mapping = { + first_name: { + '@path': '$.traits.firstName' + }, + last_name: { + '@path': '$.traits.lastName' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 2, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'events', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + // The first doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200 + }) + + // The second event fails as Braze API reports an error + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'a test error occurred', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('syncMode', () => { + it('trackEvent2 - should return a multiStatus error response when syncMode is not set', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const mapping = { + name: { + '@path': '$.traits.name' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id-1' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two', + externalId: 'test-external-id-2' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent2', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + }, + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + } + ]) + }) + + it('trackPurchase2 - should return a multiStatus error response when syncMode is not set', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const mapping = { + time: receivedAt, + external_id: { + '@path': '$.properties.externalId' + }, + products: { + '@path': '$.properties.products' + } + } + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase2', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + }, + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + } + ]) + }) + + it('updateUserProfile2 - should return a multiStatus error response when syncMode is not set', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const mapping = { + first_name: { + '@path': '$.traits.firstName' + }, + last_name: { + '@path': '$.traits.lastName' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id-1' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id-2' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile2', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + }, + { + errormessage: 'Invalid syncMode, must be set to "add" or "update"', + errorreporter: 'DESTINATION', + errortype: 'PAYLOAD_VALIDATION_FAILED', + status: 400 + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/trackEvent2/index.ts b/packages/destination-actions/src/destinations/braze/trackEvent2/index.ts index 51eda88150..7752d9c74d 100644 --- a/packages/destination-actions/src/destinations/braze/trackEvent2/index.ts +++ b/packages/destination-actions/src/destinations/braze/trackEvent2/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import { IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { sendTrackEvent, sendBatchedTrackEvent } from '../utils' +import { sendTrackEvent, sendBatchedTrackEvent, generateMultiStatusError } from '../utils' const action: ActionDefinition = { title: 'Track Event V2', @@ -114,7 +114,9 @@ const action: ActionDefinition = { if (syncMode === 'add' || syncMode === 'update') { return sendBatchedTrackEvent(request, settings, payload, syncMode) } - throw new IntegrationError('syncMode must be "add" or "update"', 'Invalid syncMode', 400) + + // Return a multi-status error if the syncMode is invalid + return generateMultiStatusError(payload.length, 'Invalid syncMode, must be set to "add" or "update"') } } diff --git a/packages/destination-actions/src/destinations/braze/trackPurchase2/index.ts b/packages/destination-actions/src/destinations/braze/trackPurchase2/index.ts index aec0047f8d..30666c0653 100644 --- a/packages/destination-actions/src/destinations/braze/trackPurchase2/index.ts +++ b/packages/destination-actions/src/destinations/braze/trackPurchase2/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import { IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { sendBatchedTrackPurchase, sendTrackPurchase } from '../utils' +import { generateMultiStatusError, sendBatchedTrackPurchase, sendTrackPurchase } from '../utils' const action: ActionDefinition = { title: 'Track Purchase V2', @@ -132,7 +132,9 @@ const action: ActionDefinition = { if (syncMode === 'add' || syncMode === 'update') { return sendBatchedTrackPurchase(request, settings, payload, syncMode) } - throw new IntegrationError('syncMode must be "add" or "update"', 'Invalid syncMode', 400) + + // Return a multi-status error if the syncMode is invalid + return generateMultiStatusError(payload.length, 'Invalid syncMode, must be set to "add" or "update"') } } diff --git a/packages/destination-actions/src/destinations/braze/updateUserProfile2/index.ts b/packages/destination-actions/src/destinations/braze/updateUserProfile2/index.ts index 21f4bb85da..e9c798b17f 100644 --- a/packages/destination-actions/src/destinations/braze/updateUserProfile2/index.ts +++ b/packages/destination-actions/src/destinations/braze/updateUserProfile2/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { updateUserProfile, updateBatchedUserProfile } from '../utils' +import { updateUserProfile, updateBatchedUserProfile, generateMultiStatusError } from '../utils' import { IntegrationError } from '@segment/actions-core' const action: ActionDefinition = { @@ -307,7 +307,9 @@ const action: ActionDefinition = { if (syncMode === 'update') { return updateBatchedUserProfile(request, settings, payload, syncMode) } - throw new IntegrationError(`Sync mode ${syncMode} is not supported`, 'Invalid syncMode', 400) + + // Return a multi-status error if the syncMode is invalid + return generateMultiStatusError(payload.length, 'Invalid syncMode, must be set to "add" or "update"') } } diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index a5385e3a68..77dc135a12 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -1,4 +1,4 @@ -import { omit } from '@segment/actions-core' +import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit } from '@segment/actions-core' import { IntegrationError, RequestClient, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' import { Settings } from './generated-types' @@ -7,9 +7,22 @@ import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' import { getUserAlias } from './userAlias' +import { HTTPError } from '@segment/actions-core' type DateInput = string | Date | number | null | undefined type DateOutput = string | undefined | null +export type BrazeTrackUserAPIResponse = { + attributes_processed?: number + events_processed?: number + purchases_processed?: number + message: string + errors?: { + type: string + input_array: 'events' | 'purchases' | 'attributes' + index: number + }[] +} + function toISO8601(date: DateInput): DateOutput { if (date === null || date === undefined) { return date @@ -101,32 +114,40 @@ export function sendTrackEvent( }) } -export function sendBatchedTrackEvent( +export async function sendBatchedTrackEvent( request: RequestClient, settings: Settings, payloads: TrackEventPayload[], syncMode?: 'add' | 'update' ) { - const payload = payloads.map((payload) => { + const multiStatusResponse = new MultiStatusResponse() + + const filteredPayloads: JSONLikeObject[] = [] + + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] + + payloads.forEach((payload, originalBatchIndex) => { const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } let updateExistingOnly = payload._update_existing_only if (syncMode) { updateExistingOnly = syncMode === 'update' } - return { + const payloadToSend = { braze_id, external_id, email, @@ -137,15 +158,34 @@ export function sendBatchedTrackEvent( properties: payload.properties, _update_existing_only: updateExistingOnly } + + filteredPayloads.push(payloadToSend as JSONLikeObject) + validPayloadIndicesBitmap.push(originalBatchIndex) + + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payloadToSend as JSONLikeObject, + body: 'success' + }) }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(filteredPayloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - events: payload + events: filteredPayloads } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse } export function sendTrackPurchase( @@ -210,76 +250,104 @@ export function sendTrackPurchase( }) } -export function sendBatchedTrackPurchase( +export async function sendBatchedTrackPurchase( request: RequestClient, settings: Settings, payloads: TrackPurchasePayload[], syncMode?: 'add' | 'update' ) { - let payload = payloads - .map((payload) => { - const { braze_id, external_id, email } = payload - // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. - const user_alias = getUserAlias(payload.user_alias) - - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } - - // Skip when there are no products to send to Braze - if (payload.products.length === 0) { - return - } + const multiStatusResponse = new MultiStatusResponse() - let updateExistingOnly = payload._update_existing_only - if (syncMode) { - updateExistingOnly = syncMode === 'update' - } + const flattenedPayload: JSONLikeObject[] = [] - const base = { - braze_id, - external_id, - user_alias, - email, - app_id: settings.app_id, - time: toISO8601(payload.time), - _update_existing_only: updateExistingOnly - } + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] - const reservedKeys = Object.keys(action.fields.products.properties ?? {}) - const event_properties = omit(payload.properties, ['products']) + const reservedKeys = Object.keys(action.fields.products.properties ?? {}) - return payload.products.map(function (product) { - return { - ...base, - product_id: product.product_id, - currency: product.currency ?? 'USD', - price: product.price, - quantity: product.quantity, - properties: { - ...omit(product, reservedKeys), - ...event_properties - } - } + payloads.forEach((payload, originalBatchIndex) => { + const { braze_id, external_id, email } = payload + // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. + const user_alias = getUserAlias(payload.user_alias) + + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } + + // Filter if no products are there to send + if (payload.products.length === 0) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.' }) + return + } + + let updateExistingOnly = payload._update_existing_only + if (syncMode) { + updateExistingOnly = syncMode === 'update' + } + + const requestBase = { + braze_id, + external_id, + user_alias, + email, + app_id: settings.app_id, + time: toISO8601(payload.time), + _update_existing_only: updateExistingOnly + } + + const eventProperties = omit(payload.properties, ['products']) + + payload.products.forEach((product) => { + flattenedPayload.push({ + ...requestBase, + product_id: product.product_id, + currency: product.currency ?? 'USD', + price: product.price, + quantity: product.quantity, + properties: { + ...omit(product, reservedKeys), + ...eventProperties + } + } as JSONLikeObject) + + // Record the index of the flattened payload with the index of original batch payload + validPayloadIndicesBitmap.push(originalBatchIndex) }) - .filter((notFalsy) => notFalsy) - // flatten arrays - payload = ([] as any[]).concat(...payload) + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payload as unknown as JSONLikeObject, + body: 'success' + }) + }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(flattenedPayload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - purchases: payload + purchases: flattenedPayload } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse } export function updateUserProfile( @@ -357,33 +425,40 @@ export function updateUserProfile( }) } -export function updateBatchedUserProfile( +export async function updateBatchedUserProfile( request: RequestClient, settings: Settings, payloads: UpdateUserProfilePayload[], syncMode?: string ) { - const payload = payloads.map((payload) => { - const { braze_id, external_id, email } = payload + const multiStatusResponse = new MultiStatusResponse() + const filteredPayloads: JSONLikeObject[] = [] + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] + + // Since we merge reserved keys on top of custom_attributes we need to remove them + // to respect the customers mappings that might resolve `undefined`, without this we'd + // potentially send a value from `custom_attributes` that conflicts with their mappings. + const reservedKeys = Object.keys(action.fields) + // Push additional default keys so they are not added as custom attributes + reservedKeys.push('firstName', 'lastName', 'avatar') + + payloads.forEach((payload, originalBatchIndex) => { + const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } - - // Since we are merge reserved keys on top of custom_attributes we need to remove them - // to respect the customers mappings that might resolve `undefined`, without this we'd - // potentially send a value from `custom_attributes` that conflicts with their mappings. - const reservedKeys = Object.keys(action.fields) - // push additional default keys so they are not added as custom attributes - reservedKeys.push('firstName', 'lastName', 'avatar') + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } + const customAttrs = omit(payload.custom_attributes, reservedKeys) let updateExistingOnly = payload._update_existing_only @@ -391,7 +466,7 @@ export function updateBatchedUserProfile( updateExistingOnly = syncMode === 'update' } - return { + const payloadToSend = { ...customAttrs, braze_id, external_id, @@ -425,13 +500,121 @@ export function updateBatchedUserProfile( twitter: payload.twitter, _update_existing_only: updateExistingOnly } + + // Push valid payload to filtered array + filteredPayloads.push(payloadToSend as JSONLikeObject) + + // Record the index of the original payload in the filtered array + validPayloadIndicesBitmap.push(originalBatchIndex) + + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payloadToSend as JSONLikeObject, + body: 'success' + }) }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(filteredPayloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - attributes: payload + attributes: filteredPayloads } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse +} + +async function handleBrazeAPIResponse( + payloads: JSONLikeObject[], + apiResponse: Promise>, + multiStatusResponse: MultiStatusResponse, + validPayloadIndicesBitmap: number[] +) { + try { + const response: ModifiedResponse = await apiResponse + + // Responses were assumed to be successful by default + // If there are errors we need to update the response + if (response.data.errors && Array.isArray(response.data.errors)) { + response.data.errors.forEach((error) => { + // Resolve error's index back to the original payload's index + const indexInOriginalPayload = validPayloadIndicesBitmap[error.index] + + // Skip if the index is already marked as an error earlier + // This is to prevent overwriting the error response if the payload is already marked as an error in Track Purchase + if (multiStatusResponse.isErrorResponseAtIndex(indexInOriginalPayload)) { + return + } + + multiStatusResponse.setErrorResponseAtIndex(indexInOriginalPayload, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: error.type, + sent: payloads[indexInOriginalPayload], + body: error.type + }) + }) + } + } catch (error) { + if (error instanceof HTTPError) { + const errorResponse = error.response as ModifiedResponse + + // Iterate over the errors reported by Braze and store them at the original payload index + const parsedErrors: Set[] = new Array(payloads.length).fill(new Set()) + + if (errorResponse.data.errors && Array.isArray(errorResponse.data.errors)) { + errorResponse.data.errors.forEach((error) => { + const indexInOriginalPayload = validPayloadIndicesBitmap[error.index] + parsedErrors[indexInOriginalPayload].add(error.type) + }) + } + + for (let i = 0; i < multiStatusResponse.length(); i++) { + // Skip if the index is already marked as an error in pre-validation + if (multiStatusResponse.isErrorResponseAtIndex(i)) { + continue + } + + // Set the error response + multiStatusResponse.setErrorResponseAtIndex(i, { + status: error.response.status, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + (error?.response as ModifiedResponse)?.data?.message ?? error.message, + sent: payloads[i], + body: parsedErrors[i].size > 0 ? Array.from(parsedErrors[i]).join(', ') : undefined + }) + } + } else { + // Bubble up the error and let Actions Framework handle it + throw error + } + } +} + +function transformPayloadsType(obj: object[]) { + return obj as JSONLikeObject[] +} + +export function generateMultiStatusError(batchSize: number, errorMessage: string): MultiStatusResponse { + const multiStatusResponse = new MultiStatusResponse() + + for (let i = 0; i < batchSize; i++) { + multiStatusResponse.pushErrorResponse({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: errorMessage + }) + } + + return multiStatusResponse }