From d5edb471dc725e75677051ab3d445007e4316f2d Mon Sep 17 00:00:00 2001 From: Ankit Gupta <139338151+AnkitSegment@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:58:04 +0530 Subject: [PATCH] [MAIN] [STRATCONN] 4261 Mixpanel Multistatus support (#2536) * Added multistatus support for mixpanel * code formated: Mixpanel multistatus * Code Refactored: Mixpanel * Unit test cases added for mixpanel multistatus * Unit test cases refactored * Added code to test on stage * Added code to test on stage * Handled error response * Added body and sent on 400 error * Removed hard code value * Code refactored * Code refactored * code refactored * Features fag added for mixpanel * Updated flagon for mixpanel * Removed console logs * Added feature flag in api response of mixpanel * Added feature flag in api response of mixpanel --- .../mixpanel/__test__/multistatus.test.ts | 455 ++++++++++++++++++ .../src/destinations/mixpanel/common/utils.ts | 53 ++ .../destinations/mixpanel/trackEvent/index.ts | 46 +- .../mixpanel/trackPurchase/index.ts | 55 ++- 4 files changed, 583 insertions(+), 26 deletions(-) create mode 100644 packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts diff --git a/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts b/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts new file mode 100644 index 0000000000..7c4401d40d --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/__test__/multistatus.test.ts @@ -0,0 +1,455 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Mixpanel from '../index' +import { MixpanelTrackApiResponseType } from '../common/utils' +import { Features } from '@segment/actions-core/mapping-kit' + +beforeEach(() => { + nock.cleanAll() +}) + +const settings = { + projectToken: 'test-api-key', + apiSecret: 'test-proj-token', + apiRegion: 'US' +} + +const END_POINT = 'https://api.mixpanel.com' + +const timestamp = '2024-10-25T15:21:15.449Z' + +const testDestination = createTestIntegration(Mixpanel) + +describe('MultiStatus', () => { + describe('trackEvent', () => { + it('should successfully handle a batch of events with complete success response from Mixpanel API', async () => { + nock(END_POINT).post('/import?strict=1').reply(200, { + code: 200, + status: 'Ok', + num_records_imported: 2 + }) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings, + features + }) + expect(response[0]).toMatchObject({ + status: 200, + body: 'Ok' + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: 'Ok' + }) + }) + + it('should successfully handle a batch of events with partial success response from Mixpanel API', async () => { + nock(END_POINT) + .post('/import?strict=1') + .reply(200, { + code: 200, + status: 'Ok', + num_records_imported: 1, + failed_records: [ + { + index: 1, + insert_id: '13c0b661-f48b-51cd-ba54-97c5999169c0', + field: 'properties.time', + message: 'Payload validation error' + } + ] + }) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ timestamp, event: 'Test event' }), + // Event without any user identifier + createTestEvent({ timestamp }) + ] + delete events[1].event + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings, + features + }) + + // The first event doesn't fail as there is no error reported by Mixpanel API + expect(response[0]).toMatchObject({ + status: 200, + body: {} + }) + + // The second event fails as pre-request validation fails for not having a valid event name. + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + }) + + it('should successfully handle a batch of events with complete error response from Mixpanel API', async () => { + nock(END_POINT).post('/import?strict=1').reply(200, {}) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // InValid Event + createTestEvent({ timestamp }), + // Invalid Event + createTestEvent({ timestamp }) + ] + delete events[0].event + delete events[1].event + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings, + features + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Mixpanel API', async () => { + const mockResponse: MixpanelTrackApiResponseType = { + code: 400, + status: 'Bad Request', + num_records_imported: 1, + failed_records: [ + { + index: 1, + insert_id: '13c0b661-f48b-51cd-ba54-97c5999169c0', + field: 'properties.time', + message: 'Payload validation error' + } + ] + } + nock(END_POINT).post('/import?strict=1').reply(400, mockResponse) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings, + features + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: 'Bad Request', + sent: { + event: 'Test event' + } + }) + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'Payload validation error', + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION' + }) + }) + it('should successfully handle a batch of events with fatal error 401 response from Mixpanel API', async () => { + const mockResponse: MixpanelTrackApiResponseType = { + code: 401, + status: 'Invalid credentials', + error: 'Unauthorized' + } + nock(END_POINT).post('/import?strict=1').reply(401, mockResponse) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackEvent', { + events, + mapping, + settings, + features + }) + + expect(response).toMatchObject([ + { + status: 401, + errormessage: 'Unauthorized', + errorreporter: 'DESTINATION' + }, + { + status: 401, + errormessage: 'Unauthorized', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('trackPurchase', () => { + it('should successfully handle a batch of events with complete success response from Mixpanel API', async () => { + nock(END_POINT).post('/import?strict=1').reply(200, { + code: 200, + status: 'Ok', + num_records_imported: 2 + }) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackPurchase', { + events, + mapping, + settings, + features + }) + expect(response[0]).toMatchObject({ + status: 200, + body: 'Ok' + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: 'Ok' + }) + }) + + it('should successfully handle a batch of events with partial success response from Mixpanel API', async () => { + nock(END_POINT).post('/import?strict=1').reply(200, { + code: 200, + status: 'Ok', + num_records_imported: 1 + }) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ timestamp, event: 'Test event' }), + // Event without any user identifier + createTestEvent({ timestamp }) + ] + delete events[1].event + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackPurchase', { + events, + mapping, + settings, + features + }) + + // The first event doesn't fail as there is no error reported by Mixpanel API + expect(response[0]).toMatchObject({ + status: 200, + body: 'Ok' + }) + + // The second event fails as pre-request validation fails for not having a valid event name. + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + }) + + it('should successfully handle a batch of events with complete error response from Mixpanel API', async () => { + nock(END_POINT).post('/import?strict=1').reply(200, {}) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ timestamp }), + // Event without any user identifier + createTestEvent({ timestamp }) + ] + delete events[0].event + delete events[1].event + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackPurchase', { + events, + mapping, + settings, + features + }) + + expect(response[0]).toMatchObject({ + status: 400, + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errormessage: "The root value is missing the required field 'event'.", + errorreporter: 'INTEGRATIONS' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Mixpanel API', async () => { + const mockResponse: MixpanelTrackApiResponseType = { + code: 400, + status: 'Bad Request', + num_records_imported: 0, + failed_records: [ + { + index: 0, + insert_id: '13c0b661-f48b-51cd-ba54-97c5999169c0', + field: 'properties.time', + message: 'Payload validation error' + }, + { + index: 1, + insert_id: '13c0b661-f48b-51cd-ba54-97c5999169c0', + field: 'properties.time', + message: 'Payload validation error' + } + ] + } + nock(END_POINT).post('/import?strict=1').reply(400, mockResponse) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackPurchase', { + events, + mapping, + settings, + features + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: 'Payload validation error', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: 'Payload validation error', + errorreporter: 'DESTINATION' + } + ]) + }) + + it('should successfully handle a batch of events with fatal error 401 response from Mixpanel API', async () => { + const mockResponse: MixpanelTrackApiResponseType = { + code: 401, + status: 'Invalid credentials', + error: 'Unauthorized' + } + nock(END_POINT).post('/import?strict=1').reply(401, mockResponse) + + const mapping = { + event: { + '@path': '$.event' + } + } + const events: SegmentEvent[] = [ + createTestEvent({ timestamp, event: 'Test event' }), + createTestEvent({ timestamp, event: 'Test event' }) + ] + + const features: Features = { 'mixpanel-multistatus': true } + const response = await testDestination.executeBatch('trackPurchase', { + events, + mapping, + settings, + features + }) + + expect(response).toMatchObject([ + { + status: 401, + errormessage: 'Unauthorized', + errorreporter: 'DESTINATION' + }, + { + status: 401, + errormessage: 'Unauthorized', + errorreporter: 'DESTINATION' + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts index 460732a3f3..a64691e9ac 100644 --- a/packages/destination-actions/src/destinations/mixpanel/common/utils.ts +++ b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts @@ -1,3 +1,5 @@ +import { JSONLikeObject, ModifiedResponse, MultiStatusResponse } from '@segment/actions-core' + export enum ApiRegions { US = 'US πŸ‡ΊπŸ‡Έ', EU = 'EU πŸ‡ͺπŸ‡Ί', @@ -103,3 +105,54 @@ export function cheapGuid(maxlen?: number) { const guid = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10) return maxlen ? guid.substring(0, maxlen) : guid } + +export type MixpanelTrackApiResponseType = { + code: number + status: string + error?: string + num_records_imported?: number + failed_records?: { + index: number + insert_id: string + field: string + message: string + }[] +} + +export function handleMixPanelApiResponse( + payloadCount: number, + apiResponse: ModifiedResponse, + events: JSONLikeObject[] +) { + const multiStatusResponse = new MultiStatusResponse() + if (apiResponse.data.code === 400 || apiResponse.data.code === 200) { + for (let i = 0; i < payloadCount; i++) { + multiStatusResponse.setSuccessResponseAtIndex(i, { + status: 200, + body: apiResponse.data.status ?? 'Ok', + sent: events[i] + }) + } + + apiResponse.data.failed_records?.map((data) => { + multiStatusResponse.setErrorResponseAtIndex(data.index, { + status: 400, + errormessage: data.message, + sent: events[data.index], + body: data + }) + }) + } + if (apiResponse.data.code !== 200 && apiResponse.data.code !== 400) { + for (let i = 0; i < payloadCount; i++) { + multiStatusResponse.setErrorResponseAtIndex(i, { + status: apiResponse.data.code, + errormessage: apiResponse.data.error ?? 'Unknown error from Mixpanel', + sent: events[i], + body: apiResponse.data.error + }) + } + } + + return multiStatusResponse +} diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts index ceb473230d..2752f197fb 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts @@ -5,6 +5,8 @@ import { MixpanelEvent } from '../mixpanel-types' import { getApiServerUrl } from '../common/utils' import { getEventProperties } from './functions' import { eventProperties } from '../mixpanel-properties' +import { MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' +import { Features } from '@segment/actions-core/mapping-kit' const getEventFromPayload = (payload: Payload, settings: Settings): MixpanelEvent => { const event: MixpanelEvent = { @@ -16,16 +18,36 @@ const getEventFromPayload = (payload: Payload, settings: Settings): MixpanelEven return event } -const processData = async (request: RequestClient, settings: Settings, payload: Payload[]) => { - const events = payload.map((value) => getEventFromPayload(value, settings)) +const processData = async (request: RequestClient, settings: Settings, payload: Payload[], features?: Features) => { + const events: MixpanelEvent[] = payload.map((value) => { + return getEventFromPayload(value, settings) + }) + const throwHttpErrors = features && features['mixpanel-multistatus'] ? false : true - return request(`${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { - method: 'post', - json: events, - headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + const response = await callMixpanelApi(request, settings, events, throwHttpErrors) + if (features && features['mixpanel-multistatus']) { + return handleMixPanelApiResponse(payload.length, response, events) + } + return response +} + +const callMixpanelApi = async ( + request: RequestClient, + settings: Settings, + events: MixpanelEvent[], + throwHttpErrors: boolean +) => { + return await request( + `${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, + { + method: 'post', + json: events, + headers: { + authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + }, + throwHttpErrors: throwHttpErrors } - }) + ) } const action: ActionDefinition = { @@ -46,12 +68,12 @@ const action: ActionDefinition = { ...eventProperties }, - performBatch: async (request, { settings, payload }) => { - return processData(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return processData(request, settings, payload, features) }, - perform: async (request, { settings, payload }) => { - return processData(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return processData(request, settings, [payload], features) } } diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts index f2564b8923..8b14e32dab 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts @@ -1,11 +1,12 @@ -import { ActionDefinition, RequestClient, omit } from '@segment/actions-core' +import { ActionDefinition, RequestClient, omit, JSONLikeObject } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { MixpanelEvent } from '../mixpanel-types' -import { getApiServerUrl, cheapGuid } from '../common/utils' +import { getApiServerUrl, cheapGuid, MixpanelTrackApiResponseType, handleMixPanelApiResponse } from '../common/utils' import { getEventProperties } from '../trackEvent/functions' import { eventProperties, productsProperties } from '../mixpanel-properties' import dayjs from '../../../lib/dayjs' +import { Features } from '@segment/actions-core/mapping-kit' const topLevelKeys = [ 'affiliation', @@ -49,15 +50,41 @@ const getPurchaseEventsFromPayload = (payload: Payload, settings: Settings): Mix return [orderCompletedEvent, ...purchaseEvents] } -const processData = async (request: RequestClient, settings: Settings, payload: Payload[]) => { - const events = payload.map((value) => getPurchaseEventsFromPayload(value, settings)).flat() - return request(`${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { - method: 'post', - json: events, - headers: { - authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` - } +const processData = async (request: RequestClient, settings: Settings, payload: Payload[], features?: Features) => { + const events: MixpanelEvent[] = [] + const sentEvents: JSONLikeObject[] = [] + payload.forEach((value) => { + const purchaseEvents = getPurchaseEventsFromPayload(value, settings).flat() + sentEvents.push(purchaseEvents as object as JSONLikeObject) + events.push(...purchaseEvents) + return purchaseEvents }) + const throwHttpErrors = features && features['mixpanel-multistatus'] ? false : true + + const response = await callMixpanelApi(request, settings, events, throwHttpErrors) + if (features && features['mixpanel-multistatus']) { + return handleMixPanelApiResponse(payload.length, response, sentEvents) + } + return response +} + +const callMixpanelApi = async ( + request: RequestClient, + settings: Settings, + events: MixpanelEvent[], + throwHttpErrors: boolean +) => { + return await request( + `${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, + { + method: 'post', + json: events, + headers: { + authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` + }, + throwHttpErrors: throwHttpErrors + } + ) } const action: ActionDefinition = { @@ -84,12 +111,12 @@ const action: ActionDefinition = { } }, - perform: async (request, { settings, payload }) => { - return processData(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return processData(request, settings, [payload], features) }, - performBatch: async (request, { settings, payload }) => { - return processData(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return processData(request, settings, payload, features) } }