diff --git a/packages/analytics-js-common/src/types/Destination.ts b/packages/analytics-js-common/src/types/Destination.ts index 8da637edd..22f15a522 100644 --- a/packages/analytics-js-common/src/types/Destination.ts +++ b/packages/analytics-js-common/src/types/Destination.ts @@ -146,6 +146,7 @@ export type DestinationConfig = BaseDestinationConfig & { export type Destination = { id: string; + originalId?: string; displayName: string; userFriendlyId: string; shouldApplyDeviceModeTransformation: boolean; diff --git a/packages/analytics-js-plugins/__tests__/deviceModeDestinations/utils.test.ts b/packages/analytics-js-plugins/__tests__/deviceModeDestinations/utils.test.ts index 320e3e273..e86f3e61c 100644 --- a/packages/analytics-js-plugins/__tests__/deviceModeDestinations/utils.test.ts +++ b/packages/analytics-js-plugins/__tests__/deviceModeDestinations/utils.test.ts @@ -581,6 +581,9 @@ describe('deviceModeDestinations utils', () => { // Enabled status should match override expect(dest1Clones[0]?.enabled).toBe(true); expect(dest1Clones[1]?.enabled).toBe(true); + // cloned destination should have originalId and originalId should be the original destination id + expect(dest1Clones[0]?.originalId).toBe('dest1'); + expect(dest1Clones[1]?.originalId).toBe('dest1'); // dest2 and dest3 should be unchanged expect(result.find(d => d.id === 'dest3')).toBe(mockDestinations[2]); @@ -606,6 +609,7 @@ describe('deviceModeDestinations utils', () => { expect(dest2Clones[0]).toBeDefined(); expect(dest2Clones[0]?.id).toBe('dest2_1'); expect(dest2Clones[0]?.userFriendlyId).toBe('dest2_friendly_1'); + expect(dest2Clones[0]?.originalId).toBe('dest2'); // Config and enabled status should match each override expect(dest2Clones[0]?.config.apiKey).toBe('cloneA'); @@ -646,6 +650,9 @@ describe('deviceModeDestinations utils', () => { expect(dest3Clones[1]?.config?.extra).toBe(123); expect(dest3Clones[0]?.enabled).toBe(true); expect(dest3Clones[1]?.enabled).toBe(true); + // cloned destination should have originalId and originalId should be the original destination id + expect(dest3Clones[0]?.originalId).toBe('dest3'); + expect(dest3Clones[1]?.originalId).toBe('dest3'); // inherit other config properties expect(dest3Clones[0]?.config?.eventFilteringOption).toBe('disable'); expect(dest3Clones[1]?.config?.eventFilteringOption).toBe('disable'); diff --git a/packages/analytics-js-plugins/__tests__/deviceModeTransformation/index.test.ts b/packages/analytics-js-plugins/__tests__/deviceModeTransformation/index.test.ts index c14fcb59f..e67ff3286 100644 --- a/packages/analytics-js-plugins/__tests__/deviceModeTransformation/index.test.ts +++ b/packages/analytics-js-plugins/__tests__/deviceModeTransformation/index.test.ts @@ -308,4 +308,375 @@ describe('Device mode transformation plugin', () => { ]); mockSendTransformedEventToDestinations.mockRestore(); }); + + describe('Cloned destinations support', () => { + it('should collect unique destination IDs from cloned destinations in enqueue', () => { + const queue = (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.init?.( + state, + defaultPluginsManager, + defaultHttpClient, + defaultStoreManager, + defaultErrorHandler, + defaultLogger, + ) as RetryQueue; + + const addItemSpy = jest.spyOn(queue, 'addItem'); + + const event = { + type: 'track', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + anonymousId: 'sampleAnonId', + messageId: 'test', + originalTimestamp: 'test', + } as unknown as RudderEvent; + + // Use only cloned destinations with same originalId + const clonedDestinations = [ + { + id: 'id4_1', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_1', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneA', + }, + }, + { + id: 'id4_2', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_2', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneB', + }, + }, + ]; + + (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.enqueue?.( + state, + queue, + event, + clonedDestinations, + ); + + // Should only include unique destination IDs based on originalId + expect(addItemSpy).toHaveBeenCalledWith({ + token: authToken, + destinationIds: ['id4'], // Only one unique ID despite multiple clones + event, + }); + + addItemSpy.mockRestore(); + }); + + it('should collect unique destination IDs from mixed original and cloned destinations', () => { + const queue = (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.init?.( + state, + defaultPluginsManager, + defaultHttpClient, + defaultStoreManager, + defaultErrorHandler, + defaultLogger, + ) as RetryQueue; + + const addItemSpy = jest.spyOn(queue, 'addItem'); + + const event = { + type: 'track', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + anonymousId: 'sampleAnonId', + messageId: 'test', + originalTimestamp: 'test', + } as unknown as RudderEvent; + + // Mix of original and cloned destinations + const mixedDestinations = [ + { + id: 'id1', + displayName: 'Destination 1', + userFriendlyId: 'Destination_568fhgvb7689', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + config: {}, + }, + { + id: 'id4_1', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_1', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneA', + }, + }, + { + id: 'id4_2', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_2', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneB', + }, + }, + ]; + + (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.enqueue?.( + state, + queue, + event, + mixedDestinations, + ); + + // Should collect unique IDs: id1 from original destination, id4 from cloned destinations + expect(addItemSpy).toHaveBeenCalledWith({ + token: authToken, + destinationIds: ['id1', 'id4'], // Two unique IDs + event, + }); + + addItemSpy.mockRestore(); + }); + + it('should process transformed events for cloned destinations successfully', () => { + // Mock successful response with cloned destination data + const dmtSuccessResponseForCloned = { + transformedBatch: [ + { + id: 'id4', // Original ID used in transformation response + payload: [ + { + orderNo: 1, + status: '200', + event: { + type: 'track', + event: 'test_transformed', + userId: 'test', + properties: { + test: 'test_transformed', + }, + }, + }, + ], + }, + ], + }; + + defaultHttpClient.getAsyncData.mockImplementation(({ callback }) => { + callback(JSON.stringify(dmtSuccessResponseForCloned), { xhr: { status: 200 } }); + }); + + const mockSendTransformedEventToDestinations = jest.spyOn( + utils, + 'sendTransformedEventToDestinations', + ); + + const queue = (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.init?.( + state, + defaultPluginsManager, + defaultHttpClient, + defaultStoreManager, + defaultErrorHandler, + defaultLogger, + ) as RetryQueue; + + const event = { + type: 'track', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + anonymousId: 'sampleAnonId', + messageId: 'test', + originalTimestamp: 'test', + } as unknown as RudderEvent; + + // Use only cloned destinations + const clonedDestinations = [ + { + id: 'id4_1', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_1', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneA', + }, + }, + { + id: 'id4_2', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_2', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneB', + }, + }, + ]; + + queue.start(); + (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.enqueue?.( + state, + queue, + event, + clonedDestinations, + ); + + expect(mockSendTransformedEventToDestinations).toHaveBeenCalledTimes(1); + expect(mockSendTransformedEventToDestinations).toHaveBeenCalledWith( + state, + defaultPluginsManager, + ['id4'], // Original ID used for transformation + JSON.stringify(dmtSuccessResponseForCloned), + 200, + event, + defaultErrorHandler, + defaultLogger, + ); + + mockSendTransformedEventToDestinations.mockRestore(); + defaultHttpClient.getAsyncData.mockRestore(); + }); + + it('should handle empty destination array without errors', () => { + const queue = (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.init?.( + state, + defaultPluginsManager, + defaultHttpClient, + defaultStoreManager, + defaultErrorHandler, + defaultLogger, + ) as RetryQueue; + + const addItemSpy = jest.spyOn(queue, 'addItem'); + + const event = { + type: 'track', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + anonymousId: 'sampleAnonId', + messageId: 'test', + originalTimestamp: 'test', + } as unknown as RudderEvent; + + (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.enqueue?.( + state, + queue, + event, + [], // Empty destinations array + ); + + expect(addItemSpy).toHaveBeenCalledWith({ + token: authToken, + destinationIds: [], // Empty array + event, + }); + + addItemSpy.mockRestore(); + }); + + it('should not duplicate destination IDs when multiple clones have different originalIds', () => { + const queue = (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.init?.( + state, + defaultPluginsManager, + defaultHttpClient, + defaultStoreManager, + defaultErrorHandler, + defaultLogger, + ) as RetryQueue; + + const addItemSpy = jest.spyOn(queue, 'addItem'); + + const event = { + type: 'track', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + anonymousId: 'sampleAnonId', + messageId: 'test', + originalTimestamp: 'test', + } as unknown as RudderEvent; + + // Destinations with different originalIds + const destinationsWithDifferentOriginalIds = [ + { + id: 'id4_1', + originalId: 'id4', + displayName: 'Destination 4', + userFriendlyId: 'Destination_123fhgvb4567_1', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneA', + }, + }, + { + id: 'id5_1', + originalId: 'id5', + displayName: 'Destination 5', + userFriendlyId: 'Destination_123fhgvb5678_1', + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneB', + }, + }, + ]; + + (DeviceModeTransformation()?.transformEvent as ExtensionPoint)?.enqueue?.( + state, + queue, + event, + destinationsWithDifferentOriginalIds, + ); + + // Should include both unique originalIds + expect(addItemSpy).toHaveBeenCalledWith({ + token: authToken, + destinationIds: ['id4', 'id5'], // Two different original IDs + event, + }); + + addItemSpy.mockRestore(); + }); + }); }); diff --git a/packages/analytics-js-plugins/__tests__/deviceModeTransformation/utilities.test.ts b/packages/analytics-js-plugins/__tests__/deviceModeTransformation/utilities.test.ts index d25930547..77ff2c54d 100644 --- a/packages/analytics-js-plugins/__tests__/deviceModeTransformation/utilities.test.ts +++ b/packages/analytics-js-plugins/__tests__/deviceModeTransformation/utilities.test.ts @@ -9,6 +9,8 @@ import { resetState, state } from '../../__mocks__/state'; import { createPayload, sendTransformedEventToDestinations, + logOncePerDestination, + getDestinationId, } from '../../src/deviceModeTransformation/utilities'; import type { RudderEvent } from '@rudderstack/analytics-js-common/types/Event'; @@ -457,5 +459,729 @@ describe('Device Mode Transformation Utilities', () => { expect(mockPluginsManager.invokeSingle).not.toHaveBeenCalled(); }); + + describe('Cloned destinations support', () => { + beforeEach(() => { + // Setup state with cloned destinations + const clonedDestinations: Destination[] = [ + { + id: 'dest3_1', + originalId: 'dest3', + displayName: 'GA4', + userFriendlyId: 'GA4___dest3_1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneA_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + { + id: 'dest3_2', + originalId: 'dest3', + displayName: 'GA4', + userFriendlyId: 'GA4___dest3_2', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: true, + cloned: true, + overridden: true, + config: { + apiKey: 'cloneB_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + { + id: 'dest4_1', + originalId: 'dest4', + displayName: 'Amplitude', + userFriendlyId: 'Amplitude___dest4_1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'clone_amplitude_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + ]; + + mockState.nativeDestinations.initializedDestinations.value = [ + ...mockDestinations, + ...clonedDestinations, + ]; + }); + + it('should send events to multiple cloned destinations with same originalId', () => { + const destinationIds = ['dest3']; // Using originalId for transformation request + const transformationResponse = { + transformedBatch: [ + { + id: 'dest3', // Response uses originalId + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true }, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should invoke both cloned destinations + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(2); + + // Check both clones were called + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + expect(calls[0]?.[3]?.id).toBe('dest3_1'); + expect(calls[1]?.[3]?.id).toBe('dest3_2'); + + // Both should receive the transformed event + expect(calls[0]?.[2]).toEqual({ ...sampleEvent, transformed: true }); + expect(calls[1]?.[2]).toEqual({ ...sampleEvent, transformed: true }); + }); + + it('should handle transformation failure for cloned destinations with different propagation settings', () => { + const destinationIds = ['dest3']; // Using originalId + const transformationResponse = { + transformedBatch: [ + { + id: 'dest3', + payload: [ + { + status: '410', // Transformation not available + event: null, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should be called once for the clone with propagateEventsUntransformedOnError=true + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(1); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + expect(calls[0]?.[3]?.id).toBe('dest3_2'); // The one with propagateEventsUntransformedOnError=true + expect(calls[0]?.[2]).toEqual(sampleEvent); // Original untransformed event + }); + + it('should handle mixed original and cloned destinations', () => { + const destinationIds = ['dest1', 'dest3']; // Mix of original and cloned + const transformationResponse = { + transformedBatch: [ + { + id: 'dest1', + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true, dest: 'dest1' }, + }, + ], + }, + { + id: 'dest3', + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true, dest: 'dest3' }, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should invoke original dest1 + both cloned dest3 destinations = 3 total + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(3); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + const calledDestIds = calls.map(call => call[3]?.id); + + expect(calledDestIds).toContain('dest1'); + expect(calledDestIds).toContain('dest3_1'); + expect(calledDestIds).toContain('dest3_2'); + }); + + it('should use originalId for destination filtering and identification', () => { + // Test that destinations are found by originalId when provided in destinationIds + const destinationIds = ['dest3', 'dest4']; + const transformationResponse = { + transformedBatch: [ + { + id: 'dest3', + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true }, + }, + ], + }, + { + id: 'dest4', + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true }, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should invoke 2 dest3 clones + 1 dest4 clone = 3 total + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(3); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + const calledDestIds = calls.map(call => call[3]?.id); + + expect(calledDestIds).toContain('dest3_1'); + expect(calledDestIds).toContain('dest3_2'); + expect(calledDestIds).toContain('dest4_1'); + }); + + it('should handle cloned destinations with different error behaviors on server errors', () => { + const destinationIds = ['dest3']; + const result = 'Internal Server Error'; + const status = 500; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Only the clone with propagateEventsUntransformedOnError=true should receive the event + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(1); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + expect(calls[0]?.[3]?.id).toBe('dest3_2'); // The one with propagateEventsUntransformedOnError=true + expect(calls[0]?.[2]).toEqual(sampleEvent); // Original untransformed event + }); + + it('should handle cloned destinations when original destination does not exist in state', () => { + // Remove original destinations but keep cloned ones + mockState.nativeDestinations.initializedDestinations.value = [ + { + id: 'dest5_1', + originalId: 'dest5', + displayName: 'Nonexistent Original Clone', + userFriendlyId: 'TestDestination___dest5_1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'clone_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + ]; + + const destinationIds = ['dest5']; + const transformationResponse = { + transformedBatch: [ + { + id: 'dest5', + payload: [ + { + status: '200', + event: { ...sampleEvent, transformed: true }, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should still work with just the cloned destination + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(1); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + expect(calls[0]?.[3]?.id).toBe('dest5_1'); + expect(calls[0]?.[2]).toEqual({ ...sampleEvent, transformed: true }); + }); + }); + + describe('Log deduplication behavior', () => { + beforeEach(() => { + // Setup destinations with multiple clones having same originalId + const destinationsWithClones: Destination[] = [ + { + id: 'dest6_1', + originalId: 'dest6', + displayName: 'TestDestination', + userFriendlyId: 'TestDestination___dest6_1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'clone1_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + { + id: 'dest6_2', + originalId: 'dest6', + displayName: 'TestDestination', + userFriendlyId: 'TestDestination___dest6_2', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'clone2_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + { + id: 'dest6_3', + originalId: 'dest6', + displayName: 'TestDestination', + userFriendlyId: 'TestDestination___dest6_3', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + cloned: true, + overridden: true, + config: { + apiKey: 'clone3_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }, + ]; + + mockState.nativeDestinations.initializedDestinations.value = destinationsWithClones; + }); + + it('should log error only once when multiple cloned destinations have transformation failures', () => { + const destinationIds = ['dest6']; + const transformationResponse = { + transformedBatch: [ + { + id: 'dest6', + payload: [ + { + status: '410', // Transformation not available + event: null, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should log error only once even though there are 3 cloned destinations + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Transformation is not available'), + ); + + // No events should be sent as propagateEventsUntransformedOnError=false for all + expect(mockPluginsManager.invokeSingle).not.toHaveBeenCalled(); + }); + + it('should log warning only once when multiple cloned destinations have server errors', () => { + const destinationIds = ['dest6']; + const result = 'Not Found'; + const status = 404; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should log warning only once despite 3 cloned destinations + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'DeviceModeTransformationPlugin:: Transformation server access is denied. The configuration data seems to be out of sync. Sending untransformed event to the destination.', + ); + + // All 3 cloned destinations should receive the untransformed event + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(3); + }); + + it('should log error only once when multiple cloned destinations have general server errors', () => { + const destinationIds = ['dest6']; + const result = 'Internal Server Error'; + const status = 500; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Should log error only once despite 3 cloned destinations + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Dropping the event'), + ); + + // No events should be sent as propagateEventsUntransformedOnError=false for all + expect(mockPluginsManager.invokeSingle).not.toHaveBeenCalled(); + }); + + it('should handle mixed cloned destinations with different propagation settings correctly', () => { + // Modify one destination to have propagateEventsUntransformedOnError=true + const modifiedDestinations = mockState.nativeDestinations.initializedDestinations.value.map( + (dest, index) => ({ + ...dest, + propagateEventsUntransformedOnError: index === 1, // Only second destination propagates + }), + ); + mockState.nativeDestinations.initializedDestinations.value = modifiedDestinations; + + const destinationIds = ['dest6']; + const transformationResponse = { + transformedBatch: [ + { + id: 'dest6', + payload: [ + { + status: '410', // Transformation not available + event: null, + }, + ], + }, + ], + }; + const result = JSON.stringify(transformationResponse); + const status = 200; + + sendTransformedEventToDestinations( + mockState, + mockPluginsManager, + destinationIds, + result, + status, + sampleEvent, + mockErrorHandler, + mockLogger, + ); + + // Since logOncePerDestination uses the destinationId (originalId) to dedupe, + // and all clones have the same originalId, only one type of log should be called + // depending on the first destination that's processed + const totalLogs = + (mockLogger.warn as jest.Mock).mock.calls.length + + (mockLogger.error as jest.Mock).mock.calls.length; + expect(totalLogs).toBe(1); + + // Only the destination with propagateEventsUntransformedOnError=true should receive event + expect(mockPluginsManager.invokeSingle).toHaveBeenCalledTimes(1); + + const calls = (mockPluginsManager.invokeSingle as jest.Mock).mock.calls; + expect(calls[0]?.[3]?.id).toBe('dest6_2'); // The middle destination with propagate=true + expect(calls[0]?.[2]).toEqual(sampleEvent); + }); + }); + }); + + describe('logOncePerDestination', () => { + let logFn: jest.Mock; + let loggedDestinations: string[]; + + beforeEach(() => { + logFn = jest.fn(); + loggedDestinations = []; + }); + + it('should call log function on first invocation for a destination', () => { + const destinationId = 'dest1'; + + logOncePerDestination(destinationId, loggedDestinations, logFn); + + expect(logFn).toHaveBeenCalledTimes(1); + expect(loggedDestinations).toContain(destinationId); + }); + + it('should not call log function on subsequent invocations for same destination', () => { + const destinationId = 'dest1'; + + // First call + logOncePerDestination(destinationId, loggedDestinations, logFn); + expect(logFn).toHaveBeenCalledTimes(1); + + // Second call with same destination + logOncePerDestination(destinationId, loggedDestinations, logFn); + expect(logFn).toHaveBeenCalledTimes(1); // Should still be 1 + expect(loggedDestinations).toEqual(['dest1']); // Should only have one entry + }); + + it('should call log function for different destinations', () => { + const dest1 = 'dest1'; + const dest2 = 'dest2'; + + logOncePerDestination(dest1, loggedDestinations, logFn); + logOncePerDestination(dest2, loggedDestinations, logFn); + + expect(logFn).toHaveBeenCalledTimes(2); + expect(loggedDestinations).toEqual([dest1, dest2]); + }); + + it('should handle multiple calls with mixed destinations correctly', () => { + const dest1 = 'dest1'; + const dest2 = 'dest2'; + + // Call sequence: dest1, dest2, dest1, dest2, dest1 + logOncePerDestination(dest1, loggedDestinations, logFn); + logOncePerDestination(dest2, loggedDestinations, logFn); + logOncePerDestination(dest1, loggedDestinations, logFn); + logOncePerDestination(dest2, loggedDestinations, logFn); + logOncePerDestination(dest1, loggedDestinations, logFn); + + expect(logFn).toHaveBeenCalledTimes(2); // Only first call for each destination + expect(loggedDestinations).toEqual([dest1, dest2]); + }); + + it('should work with pre-populated logged destinations array', () => { + const existingDest = 'existing_dest'; + const newDest = 'new_dest'; + loggedDestinations.push(existingDest); + + // Try to log for existing destination + logOncePerDestination(existingDest, loggedDestinations, logFn); + expect(logFn).not.toHaveBeenCalled(); + + // Log for new destination + logOncePerDestination(newDest, loggedDestinations, logFn); + expect(logFn).toHaveBeenCalledTimes(1); + expect(loggedDestinations).toEqual([existingDest, newDest]); + }); + + it('should handle empty string destination ID', () => { + const destinationId = ''; + + logOncePerDestination(destinationId, loggedDestinations, logFn); + + expect(logFn).toHaveBeenCalledTimes(1); + expect(loggedDestinations).toContain(''); + }); + + it('should handle complex destination IDs', () => { + const complexId = 'destination-with-special_chars.123'; + + logOncePerDestination(complexId, loggedDestinations, logFn); + logOncePerDestination(complexId, loggedDestinations, logFn); + + expect(logFn).toHaveBeenCalledTimes(1); + expect(loggedDestinations).toEqual([complexId]); + }); + + it('should call different log functions for same destination ID in different contexts', () => { + const destinationId = 'dest1'; + const logFn2 = jest.fn(); + const separateLoggedDestinations: string[] = []; + + // First context + logOncePerDestination(destinationId, loggedDestinations, logFn); + + // Second context with different loggedDestinations array + logOncePerDestination(destinationId, separateLoggedDestinations, logFn2); + + expect(logFn).toHaveBeenCalledTimes(1); + expect(logFn2).toHaveBeenCalledTimes(1); + expect(loggedDestinations).toEqual([destinationId]); + expect(separateLoggedDestinations).toEqual([destinationId]); + }); + }); + + describe('getDestinationId', () => { + it('should return originalId when available', () => { + const destination: Destination = { + id: 'dest1_clone', + originalId: 'dest1', + displayName: 'Test Destination', + userFriendlyId: 'TestDestination___dest1_clone', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + config: { + apiKey: 'test_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }; + + const result = getDestinationId(destination); + expect(result).toBe('dest1'); + }); + + it('should return id when originalId is not available', () => { + const destination: Destination = { + id: 'dest1', + displayName: 'Test Destination', + userFriendlyId: 'TestDestination___dest1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + config: { + apiKey: 'test_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }; + + const result = getDestinationId(destination); + expect(result).toBe('dest1'); + }); + + it('should return id when originalId is null', () => { + const destination: Destination = { + id: 'dest1', + originalId: null as any, + displayName: 'Test Destination', + userFriendlyId: 'TestDestination___dest1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + config: { + apiKey: 'test_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }; + + const result = getDestinationId(destination); + expect(result).toBe('dest1'); + }); + + it('should return id when originalId is undefined', () => { + const destination: Destination = { + id: 'dest1', + originalId: undefined, + displayName: 'Test Destination', + userFriendlyId: 'TestDestination___dest1', + enabled: true, + shouldApplyDeviceModeTransformation: true, + propagateEventsUntransformedOnError: false, + config: { + apiKey: 'test_key', + blacklistedEvents: [], + whitelistedEvents: [], + eventFilteringOption: 'disable' as const, + }, + }; + + const result = getDestinationId(destination); + expect(result).toBe('dest1'); + }); }); }); diff --git a/packages/analytics-js-plugins/__tests__/nativeDestinationQueue/utilities.test.ts b/packages/analytics-js-plugins/__tests__/nativeDestinationQueue/utilities.test.ts index cb0a3deb0..09aec9e23 100644 --- a/packages/analytics-js-plugins/__tests__/nativeDestinationQueue/utilities.test.ts +++ b/packages/analytics-js-plugins/__tests__/nativeDestinationQueue/utilities.test.ts @@ -123,28 +123,20 @@ describe('nativeDestinationQueue Plugin - utilities', () => { }, }; - it('should return false if cloned is undefined but shouldApplyDeviceModeTransformation is false', () => { + it('should return false if shouldApplyDeviceModeTransformation is false', () => { const dest = { ...destination, shouldApplyDeviceModeTransformation: false }; expect(shouldApplyTransformation(dest)).toBe(false); }); - it('should return true if shouldApplyDeviceModeTransformation is true and cloned is undefined', () => { + it('should return true if shouldApplyDeviceModeTransformation is true', () => { expect(shouldApplyTransformation(destination)).toBe(true); }); - it('should return true if shouldApplyDeviceModeTransformation is true and not cloned', () => { - const dest = { ...destination, cloned: false }; - expect(shouldApplyTransformation(dest)).toBe(true); - }); - - it('should return false when shouldApplyDeviceModeTransformation and cloned both are true', () => { - const dest = { ...destination, cloned: true }; - expect(shouldApplyTransformation(dest)).toBe(false); - }); - - it('should return false if both shouldApplyDeviceModeTransformation and cloned are false', () => { - const dest = { ...destination, shouldApplyDeviceModeTransformation: false, cloned: false }; - expect(shouldApplyTransformation(dest)).toBe(false); + it('should return true if shouldApplyDeviceModeTransformation is true irrespective of cloned property', () => { + const dest1 = { ...destination, cloned: true }; + const dest2 = { ...destination, cloned: undefined }; + expect(shouldApplyTransformation(dest1)).toBe(true); + expect(shouldApplyTransformation(dest2)).toBe(true); }); }); diff --git a/packages/analytics-js-plugins/src/deviceModeDestinations/utils.ts b/packages/analytics-js-plugins/src/deviceModeDestinations/utils.ts index d109ba291..dcbde25a2 100644 --- a/packages/analytics-js-plugins/src/deviceModeDestinations/utils.ts +++ b/packages/analytics-js-plugins/src/deviceModeDestinations/utils.ts @@ -262,6 +262,7 @@ const applySourceConfigurationOverrides = ( overrides.forEach((override: SourceConfigurationOverrideDestination, index: number) => { const overriddenDestination = applyOverrideToDestination(dest, override, `${index + 1}`); overriddenDestination.cloned = true; + overriddenDestination.originalId = dest.id; processedDestinations.push(overriddenDestination); }); } else { diff --git a/packages/analytics-js-plugins/src/deviceModeTransformation/index.ts b/packages/analytics-js-plugins/src/deviceModeTransformation/index.ts index 698ae478d..ebfe120f7 100644 --- a/packages/analytics-js-plugins/src/deviceModeTransformation/index.ts +++ b/packages/analytics-js-plugins/src/deviceModeTransformation/index.ts @@ -12,7 +12,7 @@ import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import type { ExtensionPlugin } from '@rudderstack/analytics-js-common/types/PluginEngine'; import type { IHttpClient } from '@rudderstack/analytics-js-common/types/HttpClient'; import type { IStoreManager } from '@rudderstack/analytics-js-common/types/Store'; -import { createPayload, sendTransformedEventToDestinations } from './utilities'; +import { createPayload, getDestinationId, sendTransformedEventToDestinations } from './utilities'; import { getDMTDeliveryPayload } from '../utilities/eventsDelivery'; import { DEFAULT_TRANSFORMATION_QUEUE_OPTIONS, QUEUE_NAME, REQUEST_TIMEOUT_MS } from './constants'; import { RetryQueue } from '../utilities/retryQueue/RetryQueue'; @@ -101,7 +101,16 @@ const DeviceModeTransformation = (): ExtensionPlugin => ({ event: RudderEvent, destinations: Destination[], ) { - const destinationIds = destinations.map(d => d.id); + // Collect unique destination IDs for transformation + // For cloned destinations, getDestinationId returns originalId to ensure uniqueness + const destinationIds: string[] = []; + destinations.forEach(d => { + const id = getDestinationId(d); + if (!destinationIds.includes(id)) { + destinationIds.push(id); + } + }); + eventsQueue.addItem({ event, destinationIds, diff --git a/packages/analytics-js-plugins/src/deviceModeTransformation/logMessages.ts b/packages/analytics-js-plugins/src/deviceModeTransformation/logMessages.ts index 093df4975..8f2cd58fb 100644 --- a/packages/analytics-js-plugins/src/deviceModeTransformation/logMessages.ts +++ b/packages/analytics-js-plugins/src/deviceModeTransformation/logMessages.ts @@ -3,21 +3,23 @@ import { LOG_CONTEXT_SEPARATOR } from '../shared-chunks/common'; const DMT_TRANSFORMATION_UNSUCCESSFUL_ERROR = ( context: string, displayName: string, + id: string, reason: string, action: string, ): string => - `${context}${LOG_CONTEXT_SEPARATOR}Event transformation unsuccessful for destination "${displayName}". Reason: ${reason}. ${action}.`; + `${context}${LOG_CONTEXT_SEPARATOR}Event transformation unsuccessful for destination "${displayName}" with id "${id}". Reason: ${reason}. ${action}.`; const DMT_REQUEST_FAILED_ERROR = ( context: string, displayName: string, + id: string, status: number | undefined, action: string, ): string => - `${context}${LOG_CONTEXT_SEPARATOR}[Destination: ${displayName}].Transformation request failed with status: ${status}. Retries exhausted. ${action}.`; + `${context}${LOG_CONTEXT_SEPARATOR}[Destination: ${displayName} with id "${id}"].Transformation request failed with status: ${status}. Retries exhausted. ${action}.`; -const DMT_EXCEPTION = (displayName: string): string => - `Transformation failed for destination "${displayName}"`; +const DMT_EXCEPTION = (displayName: string, id: string): string => + `Transformation failed for destination "${displayName}" with id "${id}"`; const DMT_SERVER_ACCESS_DENIED_WARNING = (context: string): string => `${context}${LOG_CONTEXT_SEPARATOR}Transformation server access is denied. The configuration data seems to be out of sync. Sending untransformed event to the destination.`; diff --git a/packages/analytics-js-plugins/src/deviceModeTransformation/utilities.ts b/packages/analytics-js-plugins/src/deviceModeTransformation/utilities.ts index 38f76f919..d4060e1e1 100644 --- a/packages/analytics-js-plugins/src/deviceModeTransformation/utilities.ts +++ b/packages/analytics-js-plugins/src/deviceModeTransformation/utilities.ts @@ -47,6 +47,25 @@ const createPayload = ( return payload; }; +/** + * Helper function to get destination identifier consistently + */ +const getDestinationId = (dest: Destination): string => dest.originalId ?? dest.id; + +/** + * Helper function to log once per destination to avoid duplicate messages + */ +const logOncePerDestination = ( + destinationId: string, + loggedDestinations: string[], + logFn: () => void, +): void => { + if (!loggedDestinations.includes(destinationId)) { + loggedDestinations.push(destinationId); + logFn(); + } +}; + const sendTransformedEventToDestinations = ( state: ApplicationState, pluginsManager: IPluginsManager, @@ -61,17 +80,19 @@ const sendTransformedEventToDestinations = ( const ACTION_TO_SEND_UNTRANSFORMED_EVENT = 'Sending untransformed event'; const ACTION_TO_DROP_EVENT = 'Dropping the event'; const destinations: Destination[] = state.nativeDestinations.initializedDestinations.value.filter( - d => d && destinationIds.includes(d.id), + d => d && destinationIds.includes(getDestinationId(d)), ); + const loggedDestinations: string[] = []; destinations.forEach(dest => { try { const eventsToSend: TransformedEvent[] = []; + const destinationId = getDestinationId(dest); switch (status) { case 200: { const response: TransformationResponsePayload = JSON.parse(result); const destTransformedResult = response.transformedBatch.find( - (e: TransformedBatch) => e.id === dest.id, + (e: TransformedBatch) => e.id === destinationId, ); destTransformedResult?.payload.forEach((tEvent: TransformedPayload) => { if (tEvent.status === '200') { @@ -86,23 +107,29 @@ const sendTransformedEventToDestinations = ( if (dest.propagateEventsUntransformedOnError === true) { action = ACTION_TO_SEND_UNTRANSFORMED_EVENT; eventsToSend.push(event); - logger?.warn( - DMT_TRANSFORMATION_UNSUCCESSFUL_ERROR( - DMT_PLUGIN, - dest.displayName, - reason, - action, - ), - ); + logOncePerDestination(destinationId, loggedDestinations, () => { + logger?.warn( + DMT_TRANSFORMATION_UNSUCCESSFUL_ERROR( + DMT_PLUGIN, + dest.displayName, + destinationId, + reason, + action, + ), + ); + }); } else { - logger?.error( - DMT_TRANSFORMATION_UNSUCCESSFUL_ERROR( - DMT_PLUGIN, - dest.displayName, - reason, - action, - ), - ); + logOncePerDestination(destinationId, loggedDestinations, () => { + logger?.error( + DMT_TRANSFORMATION_UNSUCCESSFUL_ERROR( + DMT_PLUGIN, + dest.displayName, + destinationId, + reason, + action, + ), + ); + }); } } }); @@ -111,25 +138,38 @@ const sendTransformedEventToDestinations = ( } // Transformation server access denied case 404: { - logger?.warn(DMT_SERVER_ACCESS_DENIED_WARNING(DMT_PLUGIN)); + logOncePerDestination(destinationId, loggedDestinations, () => { + logger?.warn(DMT_SERVER_ACCESS_DENIED_WARNING(DMT_PLUGIN)); + }); eventsToSend.push(event); break; } default: { if (dest.propagateEventsUntransformedOnError === true) { - logger?.warn( - DMT_REQUEST_FAILED_ERROR( - DMT_PLUGIN, - dest.displayName, - status, - ACTION_TO_SEND_UNTRANSFORMED_EVENT, - ), - ); + logOncePerDestination(destinationId, loggedDestinations, () => { + logger?.warn( + DMT_REQUEST_FAILED_ERROR( + DMT_PLUGIN, + dest.displayName, + destinationId, + status, + ACTION_TO_SEND_UNTRANSFORMED_EVENT, + ), + ); + }); eventsToSend.push(event); } else { - logger?.error( - DMT_REQUEST_FAILED_ERROR(DMT_PLUGIN, dest.displayName, status, ACTION_TO_DROP_EVENT), - ); + logOncePerDestination(destinationId, loggedDestinations, () => { + logger?.error( + DMT_REQUEST_FAILED_ERROR( + DMT_PLUGIN, + dest.displayName, + destinationId, + status, + ACTION_TO_DROP_EVENT, + ), + ); + }); } break; } @@ -150,10 +190,15 @@ const sendTransformedEventToDestinations = ( errorHandler?.onError({ error: err, context: DMT_PLUGIN, - customMessage: DMT_EXCEPTION(dest.displayName), + customMessage: DMT_EXCEPTION(dest.displayName, dest.id), }); } }); }; -export { createPayload, sendTransformedEventToDestinations }; +export { + createPayload, + sendTransformedEventToDestinations, + logOncePerDestination, + getDestinationId, +}; diff --git a/packages/analytics-js-plugins/src/nativeDestinationQueue/utilities.ts b/packages/analytics-js-plugins/src/nativeDestinationQueue/utilities.ts index 125d9f8e8..da773deb1 100644 --- a/packages/analytics-js-plugins/src/nativeDestinationQueue/utilities.ts +++ b/packages/analytics-js-plugins/src/nativeDestinationQueue/utilities.ts @@ -97,7 +97,7 @@ const sendEventToDestination = ( * @returns Boolean indicating whether the transformation should be applied */ const shouldApplyTransformation = (dest: Destination): boolean => { - return dest.shouldApplyDeviceModeTransformation && !dest.cloned; + return dest.shouldApplyDeviceModeTransformation; }; export {