diff --git a/packages/core/src/DdAttributes.ts b/packages/core/src/DdAttributes.ts index 91e20d5b3..9d92e600e 100644 --- a/packages/core/src/DdAttributes.ts +++ b/packages/core/src/DdAttributes.ts @@ -16,5 +16,12 @@ export const DdAttributes = { * Custom fingerprint to an error. * Expects {@link String} value. */ - errorFingerprint: '_dd.error.fingerprint' + errorFingerprint: '_dd.error.fingerprint', + + /** + * Debug ID attached to a log or a RUM event. + * The Debug ID establishes a unique connection between a bundle and its corresponding sourcemap. + * Expects {@link String} value. + */ + debugId: '_dd.debug_id' }; diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 1838542df..4ad55e714 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -34,7 +34,7 @@ import { AttributesSingleton } from './sdk/AttributesSingleton/AttributesSinglet import type { Attributes } from './sdk/AttributesSingleton/types'; import { registerNativeBridge } from './sdk/DatadogInternalBridge/DdSdkInternalNativeBridge'; import { BufferSingleton } from './sdk/DatadogProvider/Buffer/BufferSingleton'; -import { DdSdk } from './sdk/DdSdk'; +import { NativeDdSdk } from './sdk/DdSdkInternal'; import { FileBasedConfiguration } from './sdk/FileBasedConfiguration/FileBasedConfiguration'; import { GlobalState } from './sdk/GlobalState/GlobalState'; import { UserInfoSingleton } from './sdk/UserInfoSingleton/UserInfoSingleton'; @@ -83,7 +83,7 @@ export class DdSdkReactNative { SdkVerbosity.WARN ); if (!__DEV__) { - DdSdk.telemetryDebug( + NativeDdSdk.telemetryDebug( 'RN SDK was already initialized in javascript' ); } @@ -94,7 +94,7 @@ export class DdSdkReactNative { registerNativeBridge(); - await DdSdk.initialize( + await NativeDdSdk.initialize( DdSdkReactNative.buildConfiguration(configuration, params) ); @@ -187,7 +187,7 @@ export class DdSdkReactNative { `Setting attributes ${JSON.stringify(attributes)}`, SdkVerbosity.DEBUG ); - await DdSdk.setAttributes(attributes); + await NativeDdSdk.setAttributes(attributes); AttributesSingleton.getInstance().setAttributes(attributes); }; @@ -210,7 +210,7 @@ export class DdSdkReactNative { SdkVerbosity.DEBUG ); - await DdSdk.setUserInfo(userInfo); + await NativeDdSdk.setUserInfo(userInfo); UserInfoSingleton.getInstance().setUserInfo(userInfo); }; @@ -220,7 +220,7 @@ export class DdSdkReactNative { */ static clearUserInfo = async (): Promise => { InternalLog.log('Clearing user info', SdkVerbosity.DEBUG); - await DdSdk.clearUserInfo(); + await NativeDdSdk.clearUserInfo(); UserInfoSingleton.getInstance().clearUserInfo(); }; @@ -254,7 +254,7 @@ export class DdSdkReactNative { } }; - await DdSdk.addUserExtraInfo(extraUserInfo); + await NativeDdSdk.addUserExtraInfo(extraUserInfo); UserInfoSingleton.getInstance().setUserInfo(updatedUserInfo); }; @@ -265,7 +265,7 @@ export class DdSdkReactNative { */ static setTrackingConsent = (consent: TrackingConsent): Promise => { InternalLog.log(`Setting consent ${consent}`, SdkVerbosity.DEBUG); - return DdSdk.setTrackingConsent(consent); + return NativeDdSdk.setTrackingConsent(consent); }; /** @@ -274,7 +274,7 @@ export class DdSdkReactNative { */ static clearAllData = (): Promise => { InternalLog.log('Clearing all data', SdkVerbosity.DEBUG); - return DdSdk.clearAllData(); + return NativeDdSdk.clearAllData(); }; private static buildConfiguration = ( @@ -356,7 +356,8 @@ export class DdSdkReactNative { configuration.resourceTracingSamplingRate, configuration.trackWatchdogTerminations, configuration.batchProcessingLevel, - configuration.initialResourceThreshold + configuration.initialResourceThreshold, + configuration.attributeEncoders ); }; diff --git a/packages/core/src/DdSdkReactNativeConfiguration.tsx b/packages/core/src/DdSdkReactNativeConfiguration.tsx index 44debb2d2..fcb4e41cc 100644 --- a/packages/core/src/DdSdkReactNativeConfiguration.tsx +++ b/packages/core/src/DdSdkReactNativeConfiguration.tsx @@ -12,6 +12,7 @@ import type { ErrorEventMapper } from './rum/eventMappers/errorEventMapper'; import type { ResourceEventMapper } from './rum/eventMappers/resourceEventMapper'; import type { FirstPartyHost } from './rum/types'; import { PropagatorType } from './rum/types'; +import type { AttributeEncoder } from './sdk/AttributesEncoding/types'; import type { LogEventMapper } from './types'; export enum VitalsUpdateFrequency { @@ -322,6 +323,22 @@ export class DdSdkReactNativeConfiguration { */ public initialResourceThreshold?: number; + /** + * Optional list of custom encoders for attributes. + * + * Each encoder defines how to detect (`check`) and transform (`encode`) + * values of a specific type that is not handled by the built-in encoders + * (e.g., domain-specific objects, custom classes). + * + * These encoders are applied before the built-in ones. If an encoder + * successfully `check` a value, its `encode` result will be used. + * + * Example use cases: + * - Serializing a custom `UUID` class into a string + * - Handling third-party library objects that are not JSON-serializable + */ + public attributeEncoders: AttributeEncoder[] = []; + /** * Determines whether the SDK should track application termination by the watchdog on iOS. Default: `false`. */ diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 57ff5d0ed..47d70bfc0 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -19,7 +19,7 @@ import { DdRumUserInteractionTracking } from '../rum/instrumentation/interaction import { DdRumResourceTracking } from '../rum/instrumentation/resourceTracking/DdRumResourceTracking'; import { PropagatorType, RumActionType } from '../rum/types'; import { AttributesSingleton } from '../sdk/AttributesSingleton/AttributesSingleton'; -import { DdSdk } from '../sdk/DdSdk'; +import { NativeDdSdk } from '../sdk/DdSdkInternal'; import { GlobalState } from '../sdk/GlobalState/GlobalState'; import { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton'; import { ErrorSource } from '../types'; @@ -428,7 +428,9 @@ describe('DdSdkReactNative', () => { const ddSdkConfiguration = NativeModules.DdSdk.initialize.mock .calls[0][0] as DdSdkConfiguration; expect( - ddSdkConfiguration.additionalConfiguration['_dd.version'] + (ddSdkConfiguration.additionalConfiguration as { + '_dd.version': string; + })['_dd.version'] ).toBe('2.0.0'); }); @@ -451,10 +453,14 @@ describe('DdSdkReactNative', () => { const ddSdkConfiguration = NativeModules.DdSdk.initialize.mock .calls[0][0] as DdSdkConfiguration; expect( - ddSdkConfiguration.additionalConfiguration['_dd.version'] + (ddSdkConfiguration.additionalConfiguration as { + '_dd.version': string; + })['_dd.version'] ).toBeUndefined(); expect( - ddSdkConfiguration.additionalConfiguration['_dd.version_suffix'] + (ddSdkConfiguration.additionalConfiguration as { + '_dd.version_suffix': string; + })['_dd.version_suffix'] ).toBe('-codepush-3'); }); @@ -478,10 +484,14 @@ describe('DdSdkReactNative', () => { const ddSdkConfiguration = NativeModules.DdSdk.initialize.mock .calls[0][0] as DdSdkConfiguration; expect( - ddSdkConfiguration.additionalConfiguration['_dd.version'] + (ddSdkConfiguration.additionalConfiguration as { + '_dd.version': string; + })['_dd.version'] ).toBe('2.0.0-codepush-3'); expect( - ddSdkConfiguration.additionalConfiguration['_dd.version_suffix'] + (ddSdkConfiguration.additionalConfiguration as { + '_dd.version_suffix': string; + })['_dd.version_suffix'] ).toBeUndefined(); }); @@ -1055,8 +1065,8 @@ describe('DdSdkReactNative', () => { await DdSdkReactNative.setAttributes(attributes); // THEN - expect(DdSdk.setAttributes).toHaveBeenCalledTimes(1); - expect(DdSdk.setAttributes).toHaveBeenCalledWith(attributes); + expect(NativeDdSdk.setAttributes).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.setAttributes).toHaveBeenCalledWith(attributes); expect(AttributesSingleton.getInstance().getAttributes()).toEqual({ foo: 'bar' }); @@ -1079,8 +1089,8 @@ describe('DdSdkReactNative', () => { await DdSdkReactNative.setUserInfo(userInfo); // THEN - expect(DdSdk.setUserInfo).toHaveBeenCalledTimes(1); - expect(DdSdk.setUserInfo).toHaveBeenCalledWith(userInfo); + expect(NativeDdSdk.setUserInfo).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.setUserInfo).toHaveBeenCalledWith(userInfo); expect(UserInfoSingleton.getInstance().getUserInfo()).toEqual( userInfo ); @@ -1100,8 +1110,10 @@ describe('DdSdkReactNative', () => { await DdSdkReactNative.addUserExtraInfo(extraInfo); // THEN - expect(DdSdk.addUserExtraInfo).toHaveBeenCalledTimes(1); - expect(DdSdk.addUserExtraInfo).toHaveBeenCalledWith(extraInfo); + expect(NativeDdSdk.addUserExtraInfo).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.addUserExtraInfo).toHaveBeenCalledWith( + extraInfo + ); expect(UserInfoSingleton.getInstance().getUserInfo()).toEqual({ id: 'id', extraInfo: { @@ -1130,8 +1142,8 @@ describe('DdSdkReactNative', () => { await DdSdkReactNative.clearUserInfo(); // THEN - expect(DdSdk.clearUserInfo).toHaveBeenCalledTimes(1); - expect(DdSdk.setUserInfo).toHaveBeenCalled(); + expect(NativeDdSdk.clearUserInfo).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.setUserInfo).toHaveBeenCalled(); expect(UserInfoSingleton.getInstance().getUserInfo()).toEqual( undefined ); @@ -1148,8 +1160,10 @@ describe('DdSdkReactNative', () => { DdSdkReactNative.setTrackingConsent(consent); // THEN - expect(DdSdk.setTrackingConsent).toHaveBeenCalledTimes(1); - expect(DdSdk.setTrackingConsent).toHaveBeenCalledWith(consent); + expect(NativeDdSdk.setTrackingConsent).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.setTrackingConsent).toHaveBeenCalledWith( + consent + ); }); }); @@ -1159,7 +1173,7 @@ describe('DdSdkReactNative', () => { DdSdkReactNative.clearAllData(); // THEN - expect(DdSdk.clearAllData).toHaveBeenCalledTimes(1); + expect(NativeDdSdk.clearAllData).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index b1522ef7f..a52d06924 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -34,6 +34,7 @@ describe('DdSdkReactNativeConfiguration', () => { "actionEventMapper": null, "additionalConfiguration": {}, "applicationId": "fake-app-id", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "MEDIUM", "bundleLogsWithRum": true, @@ -133,6 +134,7 @@ describe('DdSdkReactNativeConfiguration', () => { "additionalField": "fake-value", }, "applicationId": "fake-app-id", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "LARGE", "bundleLogsWithRum": true, @@ -220,6 +222,7 @@ describe('DdSdkReactNativeConfiguration', () => { "actionEventMapper": null, "additionalConfiguration": {}, "applicationId": "", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "MEDIUM", "bundleLogsWithRum": false, diff --git a/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx index fc34de5b0..cfc8bfbb8 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx @@ -18,13 +18,13 @@ let baseErrorHandlerCalled = false; const baseErrorHandler = (error: any, isFatal?: boolean) => { baseErrorHandlerCalled = true; }; -let originalErrorHandler; +let originalErrorHandler: any; let baseConsoleErrorCalled = false; -const baseConsoleError = (...params: unknown) => { +const baseConsoleError = (...params: unknown[]) => { baseConsoleErrorCalled = true; }; -let originalConsoleError; +let originalConsoleError: any; const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate); @@ -61,11 +61,14 @@ it('M intercept and send a RUM event W onGlobalError() {no message}', async () = // THEN expect(DdRum.addError).toHaveBeenCalledTimes(1); expect(DdRum.addError).toHaveBeenCalledWith( - '[object Object]', + 'Unknown Error', 'SOURCE', 'doSomething() at ./path/to/file.js:67:3', { - '_dd.error.raw': error, + '_dd.error.raw.name': 'Error', + '_dd.error.raw.message': 'Unknown Error', + '_dd.error.raw.cause': undefined, + '_dd.error.raw.stack': 'doSomething() at ./path/to/file.js:67:3', '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -94,7 +97,10 @@ it('M intercept and send a RUM event W onGlobalError() {empty stack trace}', asy 'SOURCE', '', { - '_dd.error.raw': error, + '_dd.error.raw.name': 'Error', + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.cause': undefined, + '_dd.error.raw.stack': '', '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -121,14 +127,17 @@ it('M intercept and send a RUM event W onGlobalError() {Error object}', async () 'SOURCE', expect.stringContaining('Error: Something bad happened'), { - '_dd.error.raw': error, + '_dd.error.raw.name': error.name, + '_dd.error.raw.message': error.message, + '_dd.error.raw.stack': error.stack, + '_dd.error.raw.cause': undefined, '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, expect.any(Number), '' ); - expect(DdRum.addError.mock.calls[0][2]).toContain( + expect((DdRum.addError as any).mock.calls[0][2]).toContain( '/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx' ); expect(baseErrorHandlerCalled).toStrictEqual(true); @@ -155,14 +164,17 @@ it('M intercept and send a RUM event W onGlobalError() {CustomError object}', as 'SOURCE', expect.stringContaining('Error: Something bad happened'), { - '_dd.error.raw': error, + '_dd.error.raw.name': error.name, + '_dd.error.raw.message': error.message, + '_dd.error.raw.stack': error.stack, + '_dd.error.raw.cause': undefined, '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, expect.any(Number), '' ); - expect(DdRum.addError.mock.calls[0][2]).toContain( + expect((DdRum.addError as any).mock.calls[0][2]).toContain( '/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx' ); expect(baseErrorHandlerCalled).toStrictEqual(true); @@ -190,9 +202,15 @@ it('M intercept and send a RUM event W onGlobalError() {with source file info}', 'SOURCE', 'at ./path/to/file.js:1038:57', { - '_dd.error.raw': error, '_dd.error.is_crash': is_fatal, - '_dd.error.source_type': 'react-native' + '_dd.error.source_type': 'react-native', + '_dd.error.raw.sourceURL': './path/to/file.js', + '_dd.error.raw.line': 1038, + '_dd.error.raw.column': 57, + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.name': 'Error', + '_dd.error.raw.cause': undefined, + '_dd.error.raw.stack': 'at ./path/to/file.js:1038:57' }, expect.any(Number), '' @@ -224,7 +242,63 @@ it('M intercept and send a RUM event W onGlobalError() {with component stack}', 'SOURCE', 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { - '_dd.error.raw': error, + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.name': 'Error', + '_dd.error.raw.stack': [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ].join(','), + '_dd.error.raw.cause': undefined, + '_dd.error.is_crash': is_fatal, + '_dd.error.source_type': 'react-native' + }, + expect.any(Number), + '' + ); + expect(baseErrorHandlerCalled).toStrictEqual(true); +}); + +it('M intercept and send a RUM event W onGlobalError() {with stack and component stack}', async () => { + // GIVEN + DdRumErrorTracking.startTracking(); + const is_fatal = Math.random() < 0.5; + const error = { + stack: [ + 'example() at ./path/to/file.js:77:2', + 'test() at ./path/to/index.js:22:3' + ], + componentStack: [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ], + message: 'Something bad happened' + }; + + // WHEN + DdRumErrorTracking.onGlobalError(error, is_fatal); + await flushPromises(); + + // THEN + expect(DdRum.addError).toHaveBeenCalledTimes(1); + expect(DdRum.addError).toHaveBeenCalledWith( + 'Something bad happened', + 'SOURCE', + 'example() at ./path/to/file.js:77:2,test() at ./path/to/index.js:22:3', + { + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.name': 'Error', + '_dd.error.raw.stack': [ + 'example() at ./path/to/file.js:77:2', + 'test() at ./path/to/index.js:22:3' + ].join(','), + '_dd.error.raw.componentStack': [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ], + '_dd.error.raw.cause': undefined, '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -258,7 +332,14 @@ it('M intercept and send a RUM event W onGlobalError() {with stack}', async () = 'SOURCE', 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { - '_dd.error.raw': error, + '_dd.error.raw.name': 'Error', + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.cause': undefined, + '_dd.error.raw.stack': [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ].join(','), '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -292,7 +373,14 @@ it('M intercept and send a RUM event W onGlobalError() {with stacktrace}', async 'SOURCE', 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { - '_dd.error.raw': error, + '_dd.error.raw.name': 'Error', + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.stack': [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ].join(','), + '_dd.error.raw.cause': undefined, '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -332,7 +420,14 @@ it('M not report error in console handler W onGlobalError() {with console report 'SOURCE', 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { - '_dd.error.raw': error, + '_dd.error.raw.name': 'Error', + '_dd.error.raw.cause': undefined, + '_dd.error.raw.message': 'Something bad happened', + '_dd.error.raw.stack': [ + 'doSomething() at ./path/to/file.js:67:3', + 'nestedCall() at ./path/to/file.js:1064:9', + 'root() at ./path/to/index.js:10:1' + ].join(','), '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, @@ -483,7 +578,10 @@ describe.each([ const errorMessage = message === undefined || message === null ? 'Unknown Error' - : String(message); + : typeof message?.toString === 'function' && + message.toString !== Object.prototype.toString + ? String(message) + : 'Unknown Error'; expect(DdRum.addError).toHaveBeenCalledTimes(1); expect(DdRum.addError).toHaveBeenCalledWith( errorMessage, @@ -517,14 +615,16 @@ it('M intercept and send a RUM event W on error() {called from RNErrorHandler}', 'SOURCE', expect.stringContaining('Error: Something bad happened'), { - '_dd.error.raw': error, + '_dd.error.raw.name': error.name, + '_dd.error.raw.message': error.message, + '_dd.error.raw.stack': error.stack, '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native' }, expect.any(Number), '' ); - expect(DdRum.addError.mock.calls[0][2]).toContain( + expect((DdRum.addError as any).mock.calls[0][2]).toContain( '/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx' ); expect(baseErrorHandlerCalled).toStrictEqual(true); diff --git a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx index d94d9bb82..6ee9e9c71 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx @@ -21,7 +21,7 @@ import React from 'react'; import type { DdNativeRumType } from '../../../nativeModulesTypes'; import { DdRumUserInteractionTracking } from '../../../rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { BufferSingleton } from '../../../sdk/DatadogProvider/Buffer/BufferSingleton'; -import { DdSdk } from '../../../sdk/DdSdk'; +import { NativeDdSdk } from '../../../sdk/DdSdkInternal'; const styles = StyleSheet.create({ button: { @@ -311,7 +311,7 @@ describe('startTracking memoization', () => { // GIVEN DdRumUserInteractionTracking.startTracking({}); let rendersCount = 0; - const DummyComponent = props => { + const DummyComponent = (props: { onPress: () => void }) => { rendersCount++; return ( @@ -343,7 +343,7 @@ describe('startTracking memoization', () => { // GIVEN DdRumUserInteractionTracking.startTracking({}); let rendersCount = 0; - const DummyComponent = props => { + const DummyComponent = (props: { title: string }) => { rendersCount++; return ( @@ -370,7 +370,10 @@ describe('startTracking memoization', () => { // GIVEN DdRumUserInteractionTracking.startTracking({}); let rendersCount = 0; - const DummyComponent = props => { + const DummyComponent = (props: { + onPress: () => void; + title: string; + }) => { rendersCount++; return ( @@ -410,7 +413,7 @@ describe('startTracking memoization', () => { // GIVEN DdRumUserInteractionTracking.startTracking({}); let rendersCount = 0; - const DummyComponent = props => { + const DummyComponent = (props: { onPress: () => void }) => { rendersCount++; return ( @@ -456,7 +459,7 @@ describe('startTracking', () => { jest.setMock('react/jsx-runtime', {}); DdRumUserInteractionTracking.startTracking({}); expect(DdRumUserInteractionTracking['isTracking']).toBe(true); - expect(DdSdk.telemetryDebug).toBeCalledWith( + expect(NativeDdSdk.telemetryDebug).toBeCalledWith( 'React jsx runtime does not export new jsx transform' ); }); @@ -466,7 +469,7 @@ describe('startTracking', () => { DdRumUserInteractionTracking.startTracking({}); expect(DdRumUserInteractionTracking['isTracking']).toBe(true); - expect(DdSdk.telemetryDebug).toBeCalledWith( + expect(NativeDdSdk.telemetryDebug).toBeCalledWith( 'React version does not support new jsx transform' ); }); diff --git a/packages/core/src/logs/DdLogs.ts b/packages/core/src/logs/DdLogs.ts index e1936d211..9b99dc5ee 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -8,8 +8,8 @@ import { DdAttributes } from '../DdAttributes'; import { DATADOG_MESSAGE_PREFIX, InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; import type { DdNativeLogsType } from '../nativeModulesTypes'; +import { encodeAttributes } from '../sdk/AttributesEncoding/attributesEncoding'; import type { ErrorSource, LogEventMapper } from '../types'; -import { validateContext } from '../utils/argsUtils'; import { getGlobalInstance } from '../utils/singletonUtils'; import { generateEventMapper } from './eventMapper'; @@ -37,7 +37,7 @@ const isLogWithError = ( typeof args[1] === 'string' || typeof args[2] === 'string' || typeof args[3] === 'string' || - typeof args[4] === 'object' || + (args[4] !== undefined && args[4] !== null) || typeof args[5] === 'string' ); }; @@ -55,12 +55,12 @@ class DdLogsWrapper implements DdLogsType { args[1], args[2], args[3], - validateContext(args[4]), + args[4] ?? {}, 'debug', args[5] ); } - return this.log(args[0], validateContext(args[1]), 'debug'); + return this.log(args[0], args[1] ?? {}, 'debug'); }; info = (...args: LogArguments | LogWithErrorArguments): Promise => { @@ -70,12 +70,12 @@ class DdLogsWrapper implements DdLogsType { args[1], args[2], args[3], - validateContext(args[4]), + args[4] ?? {}, 'info', args[5] ); } - return this.log(args[0], validateContext(args[1]), 'info'); + return this.log(args[0], args[1] ?? {}, 'info'); }; warn = (...args: LogArguments | LogWithErrorArguments): Promise => { @@ -85,12 +85,12 @@ class DdLogsWrapper implements DdLogsType { args[1], args[2], args[3], - validateContext(args[4]), + args[4] ?? {}, 'warn', args[5] ); } - return this.log(args[0], validateContext(args[1]), 'warn'); + return this.log(args[0], args[1] ?? {}, 'warn'); }; error = (...args: LogArguments | LogWithErrorArguments): Promise => { @@ -100,13 +100,13 @@ class DdLogsWrapper implements DdLogsType { args[1], args[2], args[3], - validateContext(args[4]), + args[4] ?? {}, 'error', args[5], args[6] ); } - return this.log(args[0], validateContext(args[1]), 'error'); + return this.log(args[0], args[1] ?? {}, 'error'); }; /** @@ -161,7 +161,10 @@ class DdLogsWrapper implements DdLogsType { this.printLogTracked(event.message, status); try { - return await this.nativeLogs[status](event.message, event.context); + return await this.nativeLogs[status]( + event.message, + encodeAttributes(event.context) + ); } catch (error) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -204,8 +207,9 @@ class DdLogsWrapper implements DdLogsType { this.printLogTracked(mappedEvent.message, status); try { + const encodedContext = encodeAttributes(mappedEvent.context); const updatedContext = { - ...mappedEvent.context, + ...encodedContext, [DdAttributes.errorSourceType]: 'react-native' }; diff --git a/packages/core/src/logs/__tests__/DdLogs.test.ts b/packages/core/src/logs/__tests__/DdLogs.test.ts index f7e848a0b..bfd252d82 100644 --- a/packages/core/src/logs/__tests__/DdLogs.test.ts +++ b/packages/core/src/logs/__tests__/DdLogs.test.ts @@ -12,7 +12,7 @@ import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; import type { DdNativeLogsType } from '../../nativeModulesTypes'; import { ErrorSource } from '../../types'; -import type { LogEventMapper } from '../../types'; +import type { LogEventMapper, LogEvent } from '../../types'; import { DdLogs } from '../DdLogs'; jest.mock('../../InternalLog', () => { @@ -38,7 +38,7 @@ describe('DdLogs', () => { context: { newContext: 'context' }, status: 'info', userInfo: {} - }; + } as LogEvent; }; DdLogs.registerLogEventMapper(logEventMapper); @@ -506,7 +506,7 @@ describe('DdLogs', () => { it('native context is an object with nested property W context is an array', async () => { await DdLogs.debug('message', [1, 2, 3]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -516,12 +516,12 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.debug('message', obj); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdLogs.debug).toHaveBeenCalledWith( 'message', @@ -549,7 +549,7 @@ describe('DdLogs', () => { it('native context is an object with nested property W context is an array', async () => { await DdLogs.warn('message', [1, 2, 3]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -559,12 +559,12 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.warn('message', obj); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdLogs.warn).toHaveBeenCalledWith( 'message', @@ -592,7 +592,7 @@ describe('DdLogs', () => { it('native context is an object with nested property W context is an array', async () => { await DdLogs.info('message', [1, 2, 3]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -602,12 +602,12 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.info('message', obj); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdLogs.info).toHaveBeenCalledWith( 'message', @@ -635,7 +635,7 @@ describe('DdLogs', () => { it('native context is an object with nested property W context is an array', async () => { await DdLogs.error('message', [1, 2, 3]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -645,12 +645,12 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.error('message', obj); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdLogs.error).toHaveBeenCalledWith( 'message', @@ -701,7 +701,7 @@ describe('DdLogs', () => { ]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -721,7 +721,7 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.debug( 'message', 'kind', @@ -731,9 +731,9 @@ describe('DdLogs', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect( @@ -791,7 +791,7 @@ describe('DdLogs', () => { ]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -809,7 +809,7 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.warn( 'message', 'kind', @@ -819,9 +819,9 @@ describe('DdLogs', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect( @@ -879,7 +879,7 @@ describe('DdLogs', () => { ]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -897,7 +897,7 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.info( 'message', 'kind', @@ -907,9 +907,9 @@ describe('DdLogs', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect( @@ -967,7 +967,7 @@ describe('DdLogs', () => { ]); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -987,7 +987,7 @@ describe('DdLogs', () => { }); it('native context is empty W context is raw type', async () => { - const obj: any = 123; + const obj: any = Symbol('invalid-context'); await DdLogs.error( 'message', 'kind', @@ -997,9 +997,9 @@ describe('DdLogs', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect( diff --git a/packages/core/src/nativeModulesTypes.ts b/packages/core/src/nativeModulesTypes.ts index b05fb6e95..9b6d71e06 100644 --- a/packages/core/src/nativeModulesTypes.ts +++ b/packages/core/src/nativeModulesTypes.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { AttributeEncoder } from './sdk/AttributesEncoding/types'; import type { Spec as NativeDdLogs } from './specs/NativeDdLogs'; import type { Spec as NativeDdRum } from './specs/NativeDdRum'; import type { Spec as NativeDdSdk } from './specs/NativeDdSdk'; @@ -36,7 +37,8 @@ export class DdNativeSdkConfiguration { readonly sampleRate: number, readonly site: string, readonly trackingConsent: string, - readonly additionalConfiguration: object // eslint-disable-next-line no-empty-function + readonly additionalConfiguration: object, + readonly attributeEncoders: AttributeEncoder[] // eslint-disable-next-line no-empty-function ) {} } diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 908404fc8..f9f4bcefc 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -8,13 +8,13 @@ import type { GestureResponderEvent } from 'react-native'; import { DdAttributes } from '../DdAttributes'; import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; +import { debugId } from '../metro/debugIdResolver'; import type { DdNativeRumType } from '../nativeModulesTypes'; +import { encodeAttributes } from '../sdk/AttributesEncoding/attributesEncoding'; import { bufferVoidNativeCall } from '../sdk/DatadogProvider/Buffer/bufferNativeCall'; -import { DdSdk } from '../sdk/DdSdk'; +import { NativeDdSdk } from '../sdk/DdSdkInternal'; import { GlobalState } from '../sdk/GlobalState/GlobalState'; import type { ErrorSource } from '../types'; -import { validateContext } from '../utils/argsUtils'; -import { getErrorContext } from '../utils/errorUtils'; import { getGlobalInstance } from '../utils/singletonUtils'; import { DefaultTimeProvider } from '../utils/time-provider/DefaultTimeProvider'; import type { TimeProvider } from '../utils/time-provider/TimeProvider'; @@ -72,7 +72,7 @@ class DdRumWrapper implements DdRumType { this.nativeRum.startView( key, name, - validateContext(context), + encodeAttributes(context), timestampMs ) ); @@ -85,7 +85,7 @@ class DdRumWrapper implements DdRumType { ): Promise => { InternalLog.log(`Stopping RUM View #${key}`, SdkVerbosity.DEBUG); return bufferVoidNativeCall(() => - this.nativeRum.stopView(key, validateContext(context), timestampMs) + this.nativeRum.stopView(key, encodeAttributes(context), timestampMs) ); }; @@ -104,7 +104,7 @@ class DdRumWrapper implements DdRumType { this.nativeRum.startAction( type, name, - validateContext(context), + encodeAttributes(context), timestampMs ) ); @@ -143,7 +143,7 @@ class DdRumWrapper implements DdRumType { const mappedEvent = this.actionEventMapper.applyEventMapper({ type, name, - context: validateContext(context), + context, timestampMs, actionContext }); @@ -158,7 +158,7 @@ class DdRumWrapper implements DdRumType { this.nativeRum.addAction( mappedEvent.type, mappedEvent.name, - mappedEvent.context, + encodeAttributes(mappedEvent.context), mappedEvent.timestampMs ) ); @@ -181,7 +181,7 @@ class DdRumWrapper implements DdRumType { key, method, url, - validateContext(context), + encodeAttributes(context), timestampMs ) ); @@ -201,7 +201,7 @@ class DdRumWrapper implements DdRumType { statusCode, kind, size, - context: validateContext(context), + context, timestampMs, resourceContext }); @@ -235,7 +235,7 @@ class DdRumWrapper implements DdRumType { mappedEvent.statusCode, mappedEvent.kind, mappedEvent.size, - mappedEvent.context, + encodeAttributes(mappedEvent.context), mappedEvent.timestampMs ) ); @@ -253,7 +253,7 @@ class DdRumWrapper implements DdRumType { message, source, stacktrace, - context: getErrorContext(validateContext(context)), + context, timestampMs, fingerprint: fingerprint ?? '' }); @@ -262,8 +262,13 @@ class DdRumWrapper implements DdRumType { return generateEmptyPromise(); } InternalLog.log(`Adding RUM Error “${message}”`, SdkVerbosity.DEBUG); - const updatedContext: any = mappedEvent.context; + const updatedContext = encodeAttributes(mappedEvent.context); updatedContext[DdAttributes.errorSourceType] = 'react-native'; + + if (debugId) { + updatedContext[DdAttributes.debugId] = debugId; + } + return bufferVoidNativeCall(() => this.nativeRum.addError( mappedEvent.message, @@ -394,7 +399,7 @@ class DdRumWrapper implements DdRumType { const mappedEvent = this.actionEventMapper.applyEventMapper({ type, name, - context: validateContext(context), + context, timestampMs }); if (!mappedEvent) { @@ -414,7 +419,7 @@ class DdRumWrapper implements DdRumType { this.nativeRum.stopAction( mappedEvent.type, mappedEvent.name, - mappedEvent.context, + encodeAttributes(mappedEvent.context), mappedEvent.timestampMs ) ); @@ -441,20 +446,20 @@ class DdRumWrapper implements DdRumType { return [ args[0], args[1], - validateContext(args[2]), + args[2] ?? {}, args[3] || this.timeProvider.now() ]; } if (isOldStopActionAPI(args)) { if (this.lastActionData) { - DdSdk.telemetryDebug( + NativeDdSdk.telemetryDebug( 'DDdRum.stopAction called with the old signature' ); const { type, name } = this.lastActionData; return [ type, name, - validateContext(args[0]), + args[0] ?? {}, args[1] || this.timeProvider.now() ]; } diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 7e5fc24de..4e7728c78 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -10,7 +10,7 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; import { BufferSingleton } from '../../sdk/DatadogProvider/Buffer/BufferSingleton'; -import { DdSdk } from '../../sdk/DdSdk'; +import { NativeDdSdk } from '../../sdk/DdSdkInternal'; import { GlobalState } from '../../sdk/GlobalState/GlobalState'; import { ErrorSource } from '../../types'; import { DdRum } from '../DdRum'; @@ -69,13 +69,13 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startView('key', 'name', context); expect(InternalLog.log).toHaveBeenNthCalledWith( 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.startView).toHaveBeenCalledWith( @@ -122,7 +122,7 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startView('key', 'name'); await DdRum.stopView('key', context); @@ -130,7 +130,7 @@ describe('DdRum', () => { expect(InternalLog.log).toHaveBeenNthCalledWith( 3, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.stopView).toHaveBeenCalledWith( @@ -177,13 +177,13 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startAction(RumActionType.SCROLL, 'name', context); expect(InternalLog.log).toHaveBeenNthCalledWith( 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.startAction).toHaveBeenCalledWith( @@ -236,7 +236,7 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startAction(RumActionType.SCROLL, 'name'); await DdRum.stopAction( @@ -248,7 +248,7 @@ describe('DdRum', () => { expect(InternalLog.log).toHaveBeenNthCalledWith( 3, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.stopAction).toHaveBeenCalledWith( @@ -353,14 +353,14 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startResource('key', 'method', 'url', context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 2, + 3, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.startResource).toHaveBeenCalledWith( @@ -414,15 +414,15 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.startResource('key', 'method', 'url', {}); await DdRum.stopResource('key', 200, 'other', -1, context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 2, + 3, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.stopResource).toHaveBeenCalledWith( @@ -442,7 +442,7 @@ describe('DdRum', () => { await DdRum.stopResource('key', 200, 'other', -1, context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 2, + 3, expect.anything(), SdkVerbosity.WARN ); @@ -1108,13 +1108,13 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.addAction(RumActionType.SCROLL, 'name', context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.addAction).toHaveBeenCalledWith( @@ -1130,7 +1130,7 @@ describe('DdRum', () => { await DdRum.addAction(RumActionType.SCROLL, 'name', context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -1172,7 +1172,7 @@ describe('DdRum', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdRum.addError( 'error', ErrorSource.CUSTOM, @@ -1181,9 +1181,9 @@ describe('DdRum', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdRum.addError).toHaveBeenCalledWith( @@ -1208,7 +1208,7 @@ describe('DdRum', () => { ); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), SdkVerbosity.WARN ); @@ -1257,7 +1257,7 @@ describe('DdRum', () => { test('does not call the native SDK when startAction has not been called before and using old API', async () => { await DdRum.stopAction({ user: 'me' }, 789); expect(NativeModules.DdRum.stopAction).not.toHaveBeenCalled(); - expect(DdSdk.telemetryDebug).not.toHaveBeenCalled(); + expect(NativeDdSdk.telemetryDebug).not.toHaveBeenCalled(); }); test('calls the native SDK when called with old API', async () => { @@ -1269,7 +1269,7 @@ describe('DdRum', () => { { user: 'me' }, 789 ); - expect(DdSdk.telemetryDebug).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryDebug).toHaveBeenCalledWith( 'DDdRum.stopAction called with the old signature' ); }); @@ -1283,7 +1283,7 @@ describe('DdRum', () => { {}, 456 ); - expect(DdSdk.telemetryDebug).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryDebug).toHaveBeenCalledWith( 'DDdRum.stopAction called with the old signature' ); }); diff --git a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx index 3c3ec9f65..b80309858 100644 --- a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx +++ b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx @@ -8,15 +8,15 @@ import type { ErrorHandlerCallback } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; -import { ErrorSource } from '../../types'; +import { errorEncoder } from '../../sdk/AttributesEncoding/defaultEncoders'; import { + ERROR_DEFAULT_NAME, + ERROR_EMPTY_STACKTRACE, getErrorMessage, - getErrorStackTrace, - EMPTY_STACK_TRACE, getErrorName, - DEFAULT_ERROR_NAME, - getErrorContext -} from '../../utils/errorUtils'; + getErrorStackTrace +} from '../../sdk/AttributesEncoding/errorUtils'; +import { ErrorSource } from '../../types'; import { executeWithDelay } from '../../utils/jsUtils'; import { DdRum } from '../DdRum'; @@ -72,7 +72,7 @@ export class DdRumErrorTracking { const stacktrace = getErrorStackTrace(error); this.reportError(message, ErrorSource.SOURCE, stacktrace, { '_dd.error.is_crash': isFatal, - '_dd.error.raw': error + '_dd.error.raw': errorEncoder.encode(error) }).then(async () => { DdRumErrorTracking.isInDefaultErrorHandler = true; try { @@ -96,24 +96,24 @@ export class DdRumErrorTracking { return; } - let stack: string = EMPTY_STACK_TRACE; - let errorName: string = DEFAULT_ERROR_NAME; + let stack: string = ERROR_EMPTY_STACKTRACE; + let errorName: string = ERROR_DEFAULT_NAME; for (let i = 0; i < params.length; i += 1) { const param = params[i]; const paramStack = getErrorStackTrace(param); - if (paramStack !== EMPTY_STACK_TRACE) { + if (paramStack !== ERROR_EMPTY_STACKTRACE) { stack = paramStack; } const paramErrorName = getErrorName(param); - if (paramErrorName !== DEFAULT_ERROR_NAME) { + if (paramErrorName !== ERROR_DEFAULT_NAME) { errorName = paramErrorName; } if ( - errorName !== DEFAULT_ERROR_NAME && - stack !== EMPTY_STACK_TRACE + errorName !== ERROR_DEFAULT_NAME && + stack !== ERROR_EMPTY_STACKTRACE ) { break; } @@ -140,11 +140,6 @@ export class DdRumErrorTracking { stacktrace: string, context: object = {} ): Promise => { - return DdRum.addError( - message, - source, - stacktrace, - getErrorContext(context) - ); + return DdRum.addError(message, source, stacktrace, context); }; } diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx index 03fe98262..0e273435c 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { InternalLog } from '../../../InternalLog'; import { SdkVerbosity } from '../../../SdkVerbosity'; -import { DdSdk } from '../../../sdk/DdSdk'; -import { getErrorMessage } from '../../../utils/errorUtils'; +import { getErrorMessage } from '../../../sdk/AttributesEncoding/errorUtils'; +import { NativeDdSdk } from '../../../sdk/DdSdkInternal'; import { BABEL_PLUGIN_TELEMETRY } from '../../constants'; import { DdBabelInteractionTracking } from './DdBabelInteractionTracking'; @@ -72,7 +72,7 @@ export class DdRumUserInteractionTracking { return; } - DdSdk?.sendTelemetryLog( + NativeDdSdk?.sendTelemetryLog( BABEL_PLUGIN_TELEMETRY, DdBabelInteractionTracking.getTelemetryConfig(), { onlyOnce: true } @@ -116,7 +116,7 @@ export class DdRumUserInteractionTracking { }; } } catch (e) { - DdSdk.telemetryDebug(getErrorMessage(e)); + NativeDdSdk.telemetryDebug(getErrorMessage(e)); } const originalMemo = React.memo; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts index a5725fbf4..7683b63f9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts @@ -12,7 +12,7 @@ export class XMLHttpRequestMock implements XMLHttpRequest { static readonly DONE = 4; public response: any; - public responseType: XMLHttpRequestResponseType; + public responseType: XMLHttpRequestResponseType = ''; public status: number = 0; public readyState: number = XMLHttpRequestMock.UNSENT; public requestHeaders: Map = new Map(); @@ -20,29 +20,50 @@ export class XMLHttpRequestMock implements XMLHttpRequest { // eslint-disable-next-line no-empty-function constructor() {} - responseText: string; - responseURL: string; - responseXML: Document; - statusText: string; - timeout: number; - upload: XMLHttpRequestUpload; - withCredentials: boolean; + responseText: string = ''; + responseURL: string = ''; + responseXML: Document = {} as Document; + statusText: string = ''; + timeout: number = -1; + upload: XMLHttpRequestUpload = {} as XMLHttpRequestUpload; + withCredentials: boolean = false; getAllResponseHeaders = jest.fn(); overrideMimeType = jest.fn(); - DONE: number; - HEADERS_RECEIVED: number; - LOADING: number; - OPENED: number; - UNSENT: number; + DONE = 4 as const; + HEADERS_RECEIVED = 2 as const; + LOADING = 3 as const; + OPENED = 1 as const; + UNSENT = 0 as const; addEventListener = jest.fn(); removeEventListener = jest.fn(); - onabort: (this: XMLHttpRequest, ev: ProgressEvent) => any; - onerror: (this: XMLHttpRequest, ev: ProgressEvent) => any; - onload: (this: XMLHttpRequest, ev: ProgressEvent) => any; - onloadend: (this: XMLHttpRequest, ev: ProgressEvent) => any; - onloadstart: (this: XMLHttpRequest, ev: ProgressEvent) => any; - onprogress: (this: XMLHttpRequest, ev: ProgressEvent) => any; - ontimeout: (this: XMLHttpRequest, ev: ProgressEvent) => any; + onabort: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + onerror: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + onload: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + onloadend: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + onloadstart: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + onprogress: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; + ontimeout: ( + this: XMLHttpRequest, + ev: ProgressEvent + ) => any = ev => {}; dispatchEvent(event: Event): boolean { throw new Error('Method not implemented.'); } @@ -85,14 +106,14 @@ export class XMLHttpRequestMock implements XMLHttpRequest { } setRequestHeader(header: string, value: string): void { - this.requestHeaders[header] = value; + this.requestHeaders.set(header, value); } setResponseHeader(header: string, value: string): void { - this.responseHeaders[header] = value; + this.responseHeaders.set(header, value); } getResponseHeader(header: string): string | null { - return this.responseHeaders[header]; + return this.responseHeaders.get(header) ?? null; } } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 907bfe57a..b50e29f3e 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -58,7 +58,7 @@ function randomInt(max: number): number { const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate); -let xhrProxy; +let xhrProxy: any; const hexToDecimal = (hex: string): string => { return BigInt(hex, 16).toString(10); @@ -72,6 +72,9 @@ beforeEach(() => { xhrProxy = new XHRProxy({ xhrType: XMLHttpRequestMock, resourceReporter: new ResourceReporter([]) + } as { + xhrType: typeof XMLHttpRequest; + resourceReporter: ResourceReporter; }); // we need this because with ms precision between Date.now() calls we can get 0, so we advance @@ -228,17 +231,19 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const spanId = xhr.requestHeaders[PARENT_ID_HEADER_KEY]; + const spanId = xhr.requestHeaders.get(PARENT_ID_HEADER_KEY); expect(spanId).toBeDefined(); expect(spanId).toMatch(/[1-9].+/); - const traceId = xhr.requestHeaders[TRACE_ID_HEADER_KEY]; + const traceId = xhr.requestHeaders.get(TRACE_ID_HEADER_KEY); expect(traceId).toBeDefined(); expect(traceId).toMatch(/[1-9].+/); expect(traceId !== spanId).toBeTruthy(); - expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1'); - expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM); + expect(xhr.requestHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe( + '1' + ); + expect(xhr.requestHeaders.get(ORIGIN_HEADER_KEY)).toBe(ORIGIN_RUM); }); it('does not generate spanId and traceId in request headers when no first party hosts are provided', async () => { @@ -259,8 +264,10 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined(); - expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined(); + expect(xhr.requestHeaders.get(TRACE_ID_HEADER_KEY)).toBeUndefined(); + expect( + xhr.requestHeaders.get(PARENT_ID_HEADER_KEY) + ).toBeUndefined(); }); it('does not generate spanId and traceId in request headers when the url does not match first party hosts', async () => { @@ -290,8 +297,10 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined(); - expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined(); + expect(xhr.requestHeaders.get(TRACE_ID_HEADER_KEY)).toBeUndefined(); + expect( + xhr.requestHeaders.get(PARENT_ID_HEADER_KEY) + ).toBeUndefined(); }); it('does not crash when provided URL is not a valid one', async () => { @@ -317,8 +326,10 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).toBeUndefined(); - expect(xhr.requestHeaders[PARENT_ID_HEADER_KEY]).toBeUndefined(); + expect(xhr.requestHeaders.get(TRACE_ID_HEADER_KEY)).toBeUndefined(); + expect( + xhr.requestHeaders.get(PARENT_ID_HEADER_KEY) + ).toBeUndefined(); }); it('generates spanId and traceId with 0 sampling priority in request headers when trace is not sampled', async () => { @@ -344,12 +355,16 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).not.toBeUndefined(); expect( - xhr.requestHeaders[PARENT_ID_HEADER_KEY] + xhr.requestHeaders.get(TRACE_ID_HEADER_KEY) ).not.toBeUndefined(); - expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('0'); - expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM); + expect( + xhr.requestHeaders.get(PARENT_ID_HEADER_KEY) + ).not.toBeUndefined(); + expect(xhr.requestHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe( + '0' + ); + expect(xhr.requestHeaders.get(ORIGIN_HEADER_KEY)).toBe(ORIGIN_RUM); }); it('does not origin as RUM in the request headers when startTracking() + XHR.open() + XHR.send()', async () => { @@ -370,7 +385,7 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBeUndefined(); + expect(xhr.requestHeaders.get(ORIGIN_HEADER_KEY)).toBeUndefined(); }); it('forces the agent to keep the request generated trace when startTracking() + XHR.open() + XHR.send()', async () => { @@ -396,7 +411,9 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1'); + expect(xhr.requestHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe( + '1' + ); }); it('forces the agent to discard the request generated trace when startTracking when the request is not traced', async () => { @@ -422,7 +439,9 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('0'); + expect(xhr.requestHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe( + '0' + ); }); it('adds tracecontext request headers when the host is instrumented with tracecontext and request is sampled', async () => { @@ -452,14 +471,16 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const contextHeader = xhr.requestHeaders[TRACECONTEXT_HEADER_KEY]; + const contextHeader = xhr.requestHeaders.get( + TRACECONTEXT_HEADER_KEY + ); expect(contextHeader).toMatch( /^00-[0-9a-f]{8}[0]{8}[0-9a-f]{16}-[0-9a-f]{16}-01$/ ); // Parent value of the context header is the 3rd part of it - const parentValue = contextHeader.split('-')[2]; - const stateHeader = xhr.requestHeaders[TRACESTATE_HEADER_KEY]; + const parentValue = contextHeader?.split('-')[2]; + const stateHeader = xhr.requestHeaders.get(TRACESTATE_HEADER_KEY); expect(stateHeader).toBe(`dd=s:1;o:rum;p:${parentValue}`); }); @@ -502,15 +523,19 @@ describe('XHRProxy', () => { /* ================================================================================= * Verify that the trace id in the traceparent header is a 128 bit trace ID (hex). * ================================================================================= */ - const traceparentHeader = - xhr.requestHeaders[TRACECONTEXT_HEADER_KEY]; - const traceparentTraceId = traceparentHeader.split('-')[1]; + const traceparentHeader = xhr.requestHeaders.get( + TRACECONTEXT_HEADER_KEY + ); + const traceparentTraceId = traceparentHeader?.split('-')[1]; expect(traceparentTraceId).toMatch( /^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/ ); expect( - TracingIdentifierUtils.isWithin128Bits(traceparentTraceId, 16) + TracingIdentifierUtils.isWithin128Bits( + traceparentTraceId as string, + 16 + ) ); /* ========================================================================= @@ -518,18 +543,20 @@ describe('XHRProxy', () => { * ========================================================================= */ // x-datadog-trace-id is a decimal representing the low 64 bits of the 128 bits Trace ID - const xDatadogTraceId = xhr.requestHeaders[TRACE_ID_HEADER_KEY]; + const xDatadogTraceId = xhr.requestHeaders.get(TRACE_ID_HEADER_KEY); - expect(TracingIdentifierUtils.isWithin64Bits(xDatadogTraceId)); + expect( + TracingIdentifierUtils.isWithin64Bits(xDatadogTraceId as string) + ); /* =============================================================== * Verify that the trace id in x-datadog-tags headers is HEX 16. * =============================================================== */ // x-datadog-tags is a HEX 16 contains the high 64 bits of the 128 bits Trace ID - const xDatadogTagsTraceId = xhr.requestHeaders[ - TAGS_HEADER_KEY - ].split('=')[1]; + const xDatadogTagsTraceId = xhr.requestHeaders + ?.get(TAGS_HEADER_KEY) + ?.split('=')[1] as string; expect(xDatadogTagsTraceId).toMatch(/^[a-f0-9]{16}$/); expect( @@ -540,8 +567,8 @@ describe('XHRProxy', () => { * Verify that the trace id in the b3 header is a 128 bit trace ID (hex). * ========================================================================= */ - const b3Header = xhr.requestHeaders[B3_HEADER_KEY]; - const b3TraceId = b3Header.split('-')[0]; + const b3Header = xhr.requestHeaders.get(B3_HEADER_KEY); + const b3TraceId = b3Header?.split('-')[0] as string; expect(b3TraceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/); expect(TracingIdentifierUtils.isWithin128Bits(b3TraceId, 16)); @@ -550,7 +577,9 @@ describe('XHRProxy', () => { * Verify that the trace id in the X-B3-TraceId header is a 128 bit trace ID (hex). * ================================================================================= */ - const xB3TraceId = xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY]; + const xB3TraceId = xhr.requestHeaders.get( + B3_MULTI_TRACE_ID_HEADER_KEY + ) as string; expect(xB3TraceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/); expect(TracingIdentifierUtils.isWithin128Bits(xB3TraceId, 16)); @@ -593,18 +622,21 @@ describe('XHRProxy', () => { // THEN // x-datadog-trace-id is just the low 64 bits (DECIMAL) - const datadogLowTraceValue = - xhr.requestHeaders[TRACE_ID_HEADER_KEY]; + const datadogLowTraceValue = xhr.requestHeaders.get( + TRACE_ID_HEADER_KEY + ); // We convert the low 64 bits to HEX - const datadogLowTraceValueHex = `${BigInt(datadogLowTraceValue) + const datadogLowTraceValueHex = `${BigInt( + datadogLowTraceValue as string + ) .toString(16) .padStart(16, '0')}`; // The high 64 bits are expressed in x-datadog-tags (HEX) - const datadogHighTraceValueHex = xhr.requestHeaders[ - TAGS_HEADER_KEY - ].split('=')[1]; // High HEX 64 bits + const datadogHighTraceValueHex = xhr.requestHeaders + ?.get(TAGS_HEADER_KEY) + ?.split('=')[1] as string; // High HEX 64 bits // We re-compose the full 128 bit trace-id by joining the strings const datadogTraceValue128BitHex = `${datadogHighTraceValueHex}${datadogLowTraceValueHex}`; @@ -614,18 +646,24 @@ describe('XHRProxy', () => { datadogTraceValue128BitHex ); - const datadogParentValue = xhr.requestHeaders[PARENT_ID_HEADER_KEY]; - const contextHeader = xhr.requestHeaders[TRACECONTEXT_HEADER_KEY]; - const traceContextValue = contextHeader.split('-')[1]; - const parentContextValue = contextHeader.split('-')[2]; - const b3MultiTraceHeader = - xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY]; - const b3MultiParentHeader = - xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY]; - - const b3Header = xhr.requestHeaders[B3_HEADER_KEY]; - const traceB3Value = b3Header.split('-')[0]; - const parentB3Value = b3Header.split('-')[1]; + const datadogParentValue = xhr.requestHeaders.get( + PARENT_ID_HEADER_KEY + ); + const contextHeader = xhr.requestHeaders.get( + TRACECONTEXT_HEADER_KEY + ); + const traceContextValue = contextHeader?.split('-')[1] as string; + const parentContextValue = contextHeader?.split('-')[2] as string; + const b3MultiTraceHeader = xhr.requestHeaders.get( + B3_MULTI_TRACE_ID_HEADER_KEY + ) as string; + const b3MultiParentHeader = xhr.requestHeaders.get( + B3_MULTI_SPAN_ID_HEADER_KEY + ) as string; + + const b3Header = xhr.requestHeaders.get(B3_HEADER_KEY); + const traceB3Value = b3Header?.split('-')[0] as string; + const parentB3Value = b3Header?.split('-')[1] as string; expect(hexToDecimal(traceContextValue)).toBe( datadogTraceValue128BitDec @@ -668,9 +706,11 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const traceId = xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY]; - const spanId = xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY]; - const sampled = xhr.requestHeaders[B3_MULTI_SAMPLED_HEADER_KEY]; + const traceId = xhr.requestHeaders.get( + B3_MULTI_TRACE_ID_HEADER_KEY + ); + const spanId = xhr.requestHeaders.get(B3_MULTI_SPAN_ID_HEADER_KEY); + const sampled = xhr.requestHeaders.get(B3_MULTI_SAMPLED_HEADER_KEY); expect(traceId).toMatch(/^[0-9a-f]{8}[0]{8}[0-9a-f]{16}$/); expect(spanId).toMatch(/^[0-9a-f]{16}$/); expect(sampled).toBe('1'); @@ -703,7 +743,7 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const headerValue = xhr.requestHeaders[B3_HEADER_KEY]; + const headerValue = xhr.requestHeaders.get(B3_HEADER_KEY); expect(headerValue).toMatch( /^[0-9a-f]{8}[0]{8}[0-9a-f]{16}-[0-9a-f]{16}-1$/ ); @@ -742,23 +782,29 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[B3_HEADER_KEY]).not.toBeUndefined(); + expect(xhr.requestHeaders.get(B3_HEADER_KEY)).not.toBeUndefined(); expect( - xhr.requestHeaders[B3_MULTI_TRACE_ID_HEADER_KEY] + xhr.requestHeaders.get(B3_MULTI_TRACE_ID_HEADER_KEY) ).not.toBeUndefined(); expect( - xhr.requestHeaders[B3_MULTI_SPAN_ID_HEADER_KEY] + xhr.requestHeaders.get(B3_MULTI_SPAN_ID_HEADER_KEY) + ).not.toBeUndefined(); + expect(xhr.requestHeaders.get(B3_MULTI_SAMPLED_HEADER_KEY)).toBe( + '1' + ); + expect( + xhr.requestHeaders.get(TRACECONTEXT_HEADER_KEY) ).not.toBeUndefined(); - expect(xhr.requestHeaders[B3_MULTI_SAMPLED_HEADER_KEY]).toBe('1'); expect( - xhr.requestHeaders[TRACECONTEXT_HEADER_KEY] + xhr.requestHeaders.get(TRACE_ID_HEADER_KEY) ).not.toBeUndefined(); - expect(xhr.requestHeaders[TRACE_ID_HEADER_KEY]).not.toBeUndefined(); expect( - xhr.requestHeaders[PARENT_ID_HEADER_KEY] + xhr.requestHeaders.get(PARENT_ID_HEADER_KEY) ).not.toBeUndefined(); - expect(xhr.requestHeaders[SAMPLING_PRIORITY_HEADER_KEY]).toBe('1'); - expect(xhr.requestHeaders[ORIGIN_HEADER_KEY]).toBe(ORIGIN_RUM); + expect(xhr.requestHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe( + '1' + ); + expect(xhr.requestHeaders.get(ORIGIN_HEADER_KEY)).toBe(ORIGIN_RUM); }); it('adds rum session id to baggage headers when available', async () => { @@ -796,8 +842,10 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).not.toBeUndefined(); - expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBe( + expect( + xhr.requestHeaders.get(BAGGAGE_HEADER_KEY) + ).not.toBeUndefined(); + expect(xhr.requestHeaders.get(BAGGAGE_HEADER_KEY)).toBe( 'session.id=TEST-SESSION-ID' ); }); @@ -837,7 +885,7 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined(); + expect(xhr.requestHeaders.get(BAGGAGE_HEADER_KEY)).toBeUndefined(); }); }); @@ -983,27 +1031,39 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const timings = - DdNativeRum.stopResource.mock.calls[0][4][ - '_dd.resource_timings' - ]; + const timings = DdNativeRum.stopResource.mock.calls[0][4]; if (Platform.OS === 'ios') { - expect(timings['firstByte']['startTime']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.firstByte.startTime'] + ).toBeGreaterThan(0); } else { - expect(timings['firstByte']['startTime']).toBe(0); + expect( + timings['_dd.resource_timings.firstByte.startTime'] + ).toBe(0); } - expect(timings['firstByte']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.firstByte.duration'] + ).toBeGreaterThan(0); - expect(timings['download']['startTime']).toBeGreaterThan(0); - expect(timings['download']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.download.startTime'] + ).toBeGreaterThan(0); + + expect( + timings['_dd.resource_timings.download.duration'] + ).toBeGreaterThan(0); if (Platform.OS === 'ios') { - expect(timings['fetch']['startTime']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.fetch.startTime'] + ).toBeGreaterThan(0); } else { - expect(timings['fetch']['startTime']).toBe(0); + expect(timings['_dd.resource_timings.fetch.startTime']).toBe(0); } - expect(timings['fetch']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.fetch.duration'] + ).toBeGreaterThan(0); }); it(`M generate resource timings when startTracking() + XHR.open() + XHR.send() + XHR.abort(), platform=${platform}`, async () => { @@ -1031,27 +1091,38 @@ describe('XHRProxy', () => { await flushPromises(); // THEN - const timings = - DdNativeRum.stopResource.mock.calls[0][4][ - '_dd.resource_timings' - ]; + const timings = DdNativeRum.stopResource.mock.calls[0][4]; if (Platform.OS === 'ios') { - expect(timings['firstByte']['startTime']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.firstByte.startTime'] + ).toBeGreaterThan(0); } else { - expect(timings['firstByte']['startTime']).toBe(0); + expect( + timings['_dd.resource_timings.firstByte.startTime'] + ).toBe(0); } - expect(timings['firstByte']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.firstByte.duration'] + ).toBeGreaterThan(0); - expect(timings['download']['startTime']).toBeGreaterThan(0); - expect(timings['download']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.download.startTime'] + ).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.download.duration'] + ).toBeGreaterThan(0); if (Platform.OS === 'ios') { - expect(timings['fetch']['startTime']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.fetch.startTime'] + ).toBeGreaterThan(0); } else { - expect(timings['fetch']['startTime']).toBe(0); + expect(timings['_dd.resource_timings.fetch.startTime']).toBe(0); } - expect(timings['fetch']['duration']).toBeGreaterThan(0); + expect( + timings['_dd.resource_timings.fetch.duration'] + ).toBeGreaterThan(0); }); }); @@ -1088,7 +1159,7 @@ describe('XHRProxy', () => { firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) }); DdRum.registerResourceEventMapper(event => { - event.context['body'] = JSON.parse( + (event.context as any)['body'] = JSON.parse( event.resourceContext?.response ); return event; @@ -1105,9 +1176,7 @@ describe('XHRProxy', () => { // THEN const attributes = DdNativeRum.stopResource.mock.calls[0][4]; - expect(attributes['body']).toEqual({ - body: 'content' - }); + expect(attributes['body.body']).toEqual('content'); }); }); @@ -1358,13 +1427,13 @@ describe('XHRProxy', () => { expect(attributes['_dd.graphql.variables']).toEqual('{}'); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_NAME_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_NAME_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_VARIABLES_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_VARIABLES_HEADER) ).not.toBeDefined(); }); @@ -1396,13 +1465,13 @@ describe('XHRProxy', () => { expect(attributes['_dd.graphql.variables']).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_NAME_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_NAME_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_VARIABLES_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_VARIABLES_HEADER) ).not.toBeDefined(); }); @@ -1435,13 +1504,13 @@ describe('XHRProxy', () => { expect(attributes['_dd.graphql.variables']).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_OPERATION_NAME_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_OPERATION_NAME_HEADER) ).not.toBeDefined(); expect( - xhr.requestHeaders[DATADOG_GRAPH_QL_VARIABLES_HEADER] + xhr.requestHeaders.get(DATADOG_GRAPH_QL_VARIABLES_HEADER) ).not.toBeDefined(); }); @@ -1454,16 +1523,16 @@ describe('XHRProxy', () => { firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) }); DdRum.registerResourceEventMapper(event => { - if (event.context['_dd.graphql.variables']) { + if ((event.context as any)['_dd.graphql.variables']) { const variables = JSON.parse( - event.context['_dd.graphql.variables'] + (event.context as any)['_dd.graphql.variables'] ); if (variables.password) { variables.password = '***'; } - event.context['_dd.graphql.variables'] = JSON.stringify( - variables - ); + (event.context as any)[ + '_dd.graphql.variables' + ] = JSON.stringify(variables); } return event; diff --git a/packages/core/src/sdk/AttributesEncoding/__tests__/attributesEncoding.test.ts b/packages/core/src/sdk/AttributesEncoding/__tests__/attributesEncoding.test.ts new file mode 100644 index 000000000..9cd2718ff --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/__tests__/attributesEncoding.test.ts @@ -0,0 +1,266 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DdSdk } from '../../DdSdk'; +import { encodeAttributes } from '../attributesEncoding'; +import { warn } from '../utils'; + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + warn: jest.fn() +})); + +const setEncoders = (encoders: any[]) => { + (DdSdk as any)?._setAttributeEncodersForTesting(encoders); +}; + +describe('encodeAttributes', () => { + beforeEach(() => { + (warn as jest.Mock).mockClear(); + setEncoders([]); + }); + + it('wraps root string under context', () => { + const result = encodeAttributes('foo'); + expect(result).toEqual({ context: 'foo' }); + expect(warn).toHaveBeenCalled(); + }); + + it('wraps root number under context', () => { + const result = encodeAttributes(123); + expect(result).toEqual({ context: 123 }); + expect(warn).toHaveBeenCalled(); + }); + + it('wraps root array under context', () => { + const result = encodeAttributes([1, 2, 3]); + expect(result).toEqual({ context: [1, 2, 3] }); + expect(warn).toHaveBeenCalled(); + }); + + it('drops unsupported root function', () => { + const result = encodeAttributes(() => {}); + expect(result).toEqual({}); + expect(warn).toHaveBeenCalled(); + }); + + it('drops unsupported root symbol', () => { + const result = encodeAttributes(Symbol('x')); + expect(result).toEqual({}); + expect(warn).toHaveBeenCalled(); + }); + + it('flattens nested objects using dot syntax', () => { + const input = { user: { profile: { name: 'Alice' } } }; + const result = encodeAttributes(input); + expect(result).toEqual({ 'user.profile.name': 'Alice' }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps arrays as arrays inside objects', () => { + const input = { tags: ['a', 'b'] }; + const result = encodeAttributes(input); + expect(result).toEqual({ tags: ['a', 'b'] }); + }); + + it('flattens nested arrays of objects', () => { + const input = { arr: [{ x: 1 }, { y: 2 }] }; + const result = encodeAttributes(input); + expect(result).toEqual({ + arr: [{ x: 1 }, { y: 2 }] + }); + }); + + it('applies custom attribute encoders before built-in ones', () => { + setEncoders([ + { + check: (v: any): v is Date => v instanceof Date, + encode: (d: Date) => 'CUSTOM_DATE' + } + ]); + + const result = encodeAttributes({ now: new Date() }); + expect(result).toEqual({ now: 'CUSTOM_DATE' }); + }); + + it('applies built-in Date encoder if no custom encoder is provided', () => { + const date = new Date('2020-01-01T12:00:00Z'); + const result = encodeAttributes({ now: date }); + expect(typeof result.now).toBe('string'); + expect(result.now).toContain('2020'); + }); + + it('applies built-in Error encoder', () => { + const error = new Error('boom'); + const result = encodeAttributes({ err: error }); + expect(result['err.name']).toBe('Error'); + expect(result['err.message']).toBe('boom'); + expect(result['err.stack']).toContain('Error: boom'); + }); + + it('applies built-in Map encoder', () => { + const map = new Map([ + ['k1', 1], + ['k2', { nested: 'yes' }] + ]); + const result = encodeAttributes({ data: map }); + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'k1', + keyType: 'string', + value: 1 + }), + expect.objectContaining({ + key: 'k2', + keyType: 'string', + 'value.nested': 'yes' + }) + ]) + ); + }); + + it('drops unsupported nested values', () => { + const input = { valid: 'ok', bad: () => {} }; + const result = encodeAttributes(input); + expect(result).toEqual({ valid: 'ok' }); + }); + + it('handles deeply nested objects', () => { + const deep = { level1: { level2: { level3: { value: 42 } } } }; + const result = encodeAttributes(deep); + expect(result).toEqual({ 'level1.level2.level3.value': 42 }); + }); + + it('handles object with manual dot keys', () => { + const input = { 'user.profile.name': 'Bob' }; + const result = encodeAttributes(input); + expect(result).toEqual({ 'user.profile.name': 'Bob' }); + }); + + it('handles array with mixed values', () => { + const input = [1, 'two', { nested: true }]; + const result = encodeAttributes(input); + expect(result).toEqual({ context: [1, 'two', { nested: true }] }); + }); + + it('handles empty object gracefully', () => { + const result = encodeAttributes({}); + expect(result).toEqual({}); + expect(warn).not.toHaveBeenCalled(); + }); + + it('handles empty array gracefully at root', () => { + const result = encodeAttributes([]); + expect(result).toEqual({ context: [] }); + expect(warn).toHaveBeenCalled(); + }); + + it('handles NaN and Infinity by dropping them', () => { + const result = encodeAttributes({ + bad1: NaN, + bad2: Infinity, + good: 42 + }); + expect(result).toEqual({ good: 42 }); + }); + + it('flattens object nested inside array', () => { + const input = { arr: [{ foo: 'bar' }] }; + const result = encodeAttributes(input); + expect(result).toEqual({ arr: [{ foo: 'bar' }] }); + }); + + it('handles array of arrays correctly', () => { + const input = { + matrix: [ + [1, 2], + [3, 4] + ] + }; + const result = encodeAttributes(input); + expect(result).toEqual({ + matrix: [ + [1, 2], + [3, 4] + ] + }); + }); + + it('drops functions inside arrays', () => { + const input = { arr: [1, () => {}, 3] }; + const result = encodeAttributes(input); + expect(result.arr).toEqual([1, 3]); + }); + + it('encodes nested Maps inside objects', () => { + const map = new Map([['nested', new Map([['k', 'v']])]]); + const result = encodeAttributes({ outer: map }); + expect(result.outer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'nested', + value: expect.arrayContaining([ + expect.objectContaining({ key: 'k', value: 'v' }) + ]) + }) + ]) + ); + }); + + it('handles deeply nested array of objects', () => { + const input = { items: [[{ foo: 'bar' }]] }; + const result = encodeAttributes(input); + expect(result.items).toEqual([[{ foo: 'bar' }]]); + }); + + it('handles objects with undefined values by dropping them', () => { + const input = { a: 1, b: undefined, c: 'ok' }; + const result = encodeAttributes(input); + expect(result).toEqual({ a: 1, c: 'ok' }); + }); + + it('custom encoder can override primitive handling', () => { + setEncoders([ + { + check: (v: any): v is number => typeof v === 'number', + encode: (n: number) => `num:${n}` + } + ]); + const result = encodeAttributes({ a: 5 }); + expect(result).toEqual({ a: 'num:5' }); + }); + + it('handles object with both dot syntax and nested keys without collisions', () => { + const input = { + 'user.profile.name': 'Alice', + user: { profile: { age: 30 } } + }; + const result = encodeAttributes(input); + expect(result).toEqual({ + 'user.profile.name': 'Alice', + 'user.profile.age': 30 + }); + }); + + it('handles null and undefined keys in Map', () => { + const map = new Map([ + [null, 'nullKey'], + [undefined, 'undefinedKey'] + ]); + const result = encodeAttributes({ myMap: map }); + expect(result.myMap).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'null', value: 'nullKey' }), + expect.objectContaining({ + key: 'undefined', + value: 'undefinedKey' + }) + ]) + ); + }); +}); diff --git a/packages/core/src/sdk/AttributesEncoding/__tests__/defaultEncoders.test.ts b/packages/core/src/sdk/AttributesEncoding/__tests__/defaultEncoders.test.ts new file mode 100644 index 000000000..88c0c0242 --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/__tests__/defaultEncoders.test.ts @@ -0,0 +1,198 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + stringEncoder, + numberEncoder, + booleanEncoder, + nullishEncoder, + arrayEncoder, + dateEncoder, + errorEncoder, + mapEncoder +} from '../defaultEncoders'; +import { warn } from '../utils'; + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + warn: jest.fn() +})); + +describe('default encoders', () => { + beforeEach(() => { + (warn as jest.Mock).mockClear(); + }); + + describe('stringEncoder', () => { + it('encodes a string directly', () => { + expect(stringEncoder.check('foo')).toBe(true); + expect(stringEncoder.encode('foo')).toBe('foo'); + }); + it('rejects non-strings', () => { + expect(stringEncoder.check(123)).toBe(false); + }); + }); + + describe('numberEncoder', () => { + it('encodes finite numbers', () => { + expect(numberEncoder.check(42)).toBe(true); + expect(numberEncoder.encode(42)).toBe(42); + }); + it('drops NaN and Infinity', () => { + expect(numberEncoder.encode(NaN)).toBeUndefined(); + expect(numberEncoder.encode(Infinity)).toBeUndefined(); + }); + }); + + describe('booleanEncoder', () => { + it('encodes booleans directly', () => { + expect(booleanEncoder.check(true)).toBe(true); + expect(booleanEncoder.encode(true)).toBe(true); + expect(booleanEncoder.encode(false)).toBe(false); + }); + }); + + describe('nullishEncoder', () => { + it('encodes null and undefined directly', () => { + expect(nullishEncoder.check(null)).toBe(true); + expect(nullishEncoder.check(undefined)).toBe(true); + expect(nullishEncoder.encode(null)).toBeNull(); + expect(nullishEncoder.encode(undefined)).toBeUndefined(); + }); + it('rejects non-nullish values', () => { + expect(nullishEncoder.check('')).toBe(false); + }); + }); + + describe('arrayEncoder', () => { + it('encodes array of primitives', () => { + const result = arrayEncoder.encode([1, 'a', true]); + expect(result).toEqual([1, 'a', true]); + }); + it('encodes nested objects inside array', () => { + const result = arrayEncoder.encode([{ foo: 'bar' }]); + expect((result as Record[])[0]).toHaveProperty( + 'foo', + 'bar' + ); + }); + it('encodes nested arrays recursively', () => { + const result = arrayEncoder.encode([[1, 2], ['a']]); + expect(result).toEqual([[1, 2], ['a']]); + }); + }); + + describe('dateEncoder', () => { + it('encodes Date to string', () => { + const date = new Date('2020-01-01T00:00:00Z'); + expect(dateEncoder.check(date)).toBe(true); + expect(dateEncoder.encode(date)).toEqual(String(date)); + }); + it('rejects non-Date values', () => { + expect(dateEncoder.check('2020-01-01')).toBe(false); + }); + }); + + describe('errorEncoder', () => { + it('encodes Error with name, message, and stack', () => { + const error = new Error('boom'); + const result = errorEncoder.encode(error) as Record; + expect(result.name).toBe('Error'); + expect(result.message).toBe('boom'); + expect(result.stack).toContain('Error: boom'); + }); + + it('removes duplicate fields like stack', () => { + const err = { + message: 'fail' + } as Record; + + err.name = 'CustomError'; + err.stacktrace = 'custom-stack'; + err.stack = 'error-stacktrace'; + err.componentStack = 'component-stack'; + + const result = errorEncoder.encode(err) as Record; + expect(result.name).toBe('CustomError'); + expect(result.message).toBe('fail'); + expect(result.stack).toBe('custom-stack'); + expect(result).not.toHaveProperty('stacktrace'); + expect(result).toHaveProperty('componentStack'); + }); + + it('encodes error with cause', () => { + const cause = new Error('inner'); + const err: any = new Error('outer'); + err.cause = cause; + const result = errorEncoder.encode(err) as Record; + expect(result.cause).toBe(cause); + }); + }); + + describe('mapEncoder', () => { + it('encodes map with string keys', () => { + const map = new Map([ + ['a', 1], + ['b', 'str'] + ]); + const result = mapEncoder.encode(map); + expect(result).toEqual( + expect.arrayContaining([ + { key: 'a', keyType: 'string', value: 1 }, + { key: 'b', keyType: 'string', value: 'str' } + ]) + ); + }); + + it('encodes map with object key', () => { + const keyObj = { toString: () => 'objKey' }; + const map = new Map([[keyObj, 123]]); + const result = mapEncoder.encode(map); + expect((result as Record[])[0]).toHaveProperty( + 'key', + 'objKey' + ); + expect((result as Record[])[0]).toHaveProperty( + 'keyType', + 'object' + ); + }); + + it('encodes map with symbol key', () => { + const map = new Map([[Symbol('s'), 'val']]); + const result = mapEncoder.encode(map); + expect((result as Record[])[0].key).toContain( + 'Symbol(s)' + ); + expect((result as Record[])[0].keyType).toBe('symbol'); + }); + + it('encodes map with null and undefined keys', () => { + const map = new Map([ + [null, 'nullVal'], + [undefined, 'undefVal'] + ]); + const result = mapEncoder.encode(map); + expect(result).toEqual( + expect.arrayContaining([ + { key: 'null', keyType: 'object', value: 'nullVal' }, + { + key: 'undefined', + keyType: 'undefined', + value: 'undefVal' + } + ]) + ); + }); + + it('warns and drops unsupported key types', () => { + const map = new Map([[BigInt(1), 'big']]); + const result = mapEncoder.encode(map); + expect((result as Record[])[0].key).toBe('1'); // bigint stringified + expect(warn).not.toHaveBeenCalled(); // bigint is allowed + }); + }); +}); diff --git a/packages/core/src/sdk/AttributesEncoding/attributesEncoding.ts b/packages/core/src/sdk/AttributesEncoding/attributesEncoding.ts new file mode 100644 index 000000000..0f05d7e11 --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/attributesEncoding.ts @@ -0,0 +1,41 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DdSdk } from '../DdSdk'; + +import { builtInEncoders } from './defaultEncoders'; +import { encodeAttributesInPlace } from './helpers'; +import type { Encodable } from './types'; +import { isPlainObject, warn } from './utils'; + +/** + * Encodes arbitrary input into a flat dictionary of attributes. + * - Objects are flattened using dot syntax. Max depth handling is done on the native layer by the + * Android and iOS SDKs. + * - We assume the input does not always conform to Record and we: + * - Fallback to { context: givenValue } if a primitive is passed + * - Apply built-in and consumer encoders to all values + * - Drop values of unsupported types + */ +export function encodeAttributes(input: unknown): Record { + const result: Record = {}; + const allEncoders = [...DdSdk.attributeEncoders, ...builtInEncoders]; + + if (isPlainObject(input)) { + for (const [k, v] of Object.entries(input)) { + encodeAttributesInPlace(v, result, [k], allEncoders); + } + } else { + // Fallback for primitive values passed as root + encodeAttributesInPlace(input, result, ['context'], allEncoders); + warn( + 'Warning: attributes root should be an object.\n' + + 'Received a primitive/array instead, which will be wrapped under the "context" key.' + ); + } + + return result; +} diff --git a/packages/core/src/sdk/AttributesEncoding/defaultEncoders.ts b/packages/core/src/sdk/AttributesEncoding/defaultEncoders.ts new file mode 100644 index 000000000..420b629ec --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/defaultEncoders.ts @@ -0,0 +1,175 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* ---------------------------------------- + * Built-in encoders + * -------------------------------------- */ +import { DdSdk } from '../DdSdk'; + +import { + getErrorMessage, + getErrorName, + getErrorStackTrace +} from './errorUtils'; +import { encodeAttributesInPlace, sanitizeForJson } from './helpers'; +import type { AttributeEncoder, Encodable } from './types'; +import { warn } from './utils'; + +/** Primitives: keep them explicit so the full pipeline is used uniformly. */ +export const stringEncoder: AttributeEncoder = { + check: (v): v is string => typeof v === 'string', + encode: v => v +}; + +export const numberEncoder: AttributeEncoder = { + check: (v): v is number => typeof v === 'number', + encode: v => (Number.isFinite(v) ? v : undefined) // drop non-finite +}; + +export const booleanEncoder: AttributeEncoder = { + check: (v): v is boolean => typeof v === 'boolean', + encode: v => v +}; + +export const nullishEncoder: AttributeEncoder = { + check: (v): v is null | undefined => v === null || v === undefined, + encode: v => v +}; + +/** + * Array encoder: + * - Sanitizes each item through the encoder pipeline. + * - Returns the sanitized array (it may later be flattened by the visitor if it still contains objects). + */ +export const arrayEncoder: AttributeEncoder = { + check: Array.isArray, + encode: (arr: unknown[]) => + arr.map(x => + sanitizeForJson(x, [...DdSdk.attributeEncoders, ...builtInEncoders]) + ) +}; + +/** + * Default Datadog Date Encoder. + * This does not make assumptions on format; uses String(date). + */ +export const dateEncoder: AttributeEncoder = { + check: (v: unknown): v is Date => v instanceof Date, + encode: (d: Date) => String(d) +}; + +/** + * Extended Error Encoder. + * Serializes name, message, stack, and cause (ES2022+) for Error objects. + * If the error has other enumerable properties, they are included and sanitized. + */ +export const errorEncoder: AttributeEncoder = { + check: (v: unknown): v is Error => v instanceof Error, + encode: (e: any) => { + const extraAttributes: Record = {}; + + // In React Native, some errors have extra fields we want to capture + if (e && typeof e === 'object') { + const allEncoders = [ + ...DdSdk.attributeEncoders, + ...builtInEncoders + ]; + encodeAttributesInPlace(e, extraAttributes, [], allEncoders); + } + + // Remove fields that are duplicated in the dedicated fields below + if ('stacktrace' in e) { + delete extraAttributes['stacktrace']; + } else if ('stack' in e) { + delete extraAttributes['stack']; + } else if ('componentStack' in e) { + delete extraAttributes['componentStack']; + } + + return { + ...extraAttributes, + name: getErrorName(e), + message: getErrorMessage(e), + stack: getErrorStackTrace(e), + cause: (e as any).cause + }; + } +}; + +/** + * Map encoder: + * - Converts Map into an array of entries. + * - Each entry is { key: string, keyType: string, value: Encodable }. + * - Keys are stringified with type info to reduce collision risk. + * - Entries with un-stringifiable keys are dropped (with a warning). + */ +export const mapEncoder: AttributeEncoder> = { + check: (v: unknown): v is Map => v instanceof Map, + encode: (map: Map) => { + const entries: Encodable[] = []; + + for (const [k, v] of map.entries()) { + try { + const keyType = typeof k; + let keyStr: string; + + if (k === null) { + keyStr = 'null'; + } else if (k === undefined) { + keyStr = 'undefined'; + } else if ( + keyType === 'string' || + keyType === 'number' || + keyType === 'boolean' || + keyType === 'bigint' + ) { + keyStr = String(k); + } else if (typeof k === 'symbol') { + keyStr = k.description + ? `Symbol(${k.description})` + : 'Symbol'; + } else if (typeof k === 'object' || typeof k === 'function') { + // Try to get a descriptive form + if (typeof (k as any).toString === 'function') { + keyStr = (k as any).toString(); + } else { + keyStr = Object.prototype.toString.call(k); // e.g. "[object Object]" + } + } else { + warn( + `Dropping Map entry: unsupported key type "${keyType}".` + ); + continue; + } + + const allEncoders = [ + ...DdSdk.attributeEncoders, + ...builtInEncoders + ]; + entries.push({ + key: keyStr, + keyType, + value: sanitizeForJson(v, allEncoders) + }); + } catch (err) { + warn(`Failed to encode Map key: ${k}. ERROR: ${String(err)}`); + } + } + + return entries; + } +}; + +export const builtInEncoders = [ + stringEncoder, + numberEncoder, + booleanEncoder, + nullishEncoder, + arrayEncoder, + dateEncoder, + errorEncoder, + mapEncoder +]; diff --git a/packages/core/src/utils/errorUtils.ts b/packages/core/src/sdk/AttributesEncoding/errorUtils.ts similarity index 56% rename from packages/core/src/utils/errorUtils.ts rename to packages/core/src/sdk/AttributesEncoding/errorUtils.ts index 5fc16dcbc..b8692959b 100644 --- a/packages/core/src/utils/errorUtils.ts +++ b/packages/core/src/sdk/AttributesEncoding/errorUtils.ts @@ -3,25 +3,9 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ - -import { debugId } from '../metro/debugIdResolver'; - -export const EMPTY_MESSAGE = 'Unknown Error'; -export const EMPTY_STACK_TRACE = ''; -export const DEFAULT_ERROR_NAME = 'Error'; - -export const getErrorMessage = (error: any | undefined): string => { - let message = EMPTY_MESSAGE; - if (error === undefined || error === null) { - message = EMPTY_MESSAGE; - } else if (typeof error === 'object' && 'message' in error) { - message = String(error.message); - } else { - message = String(error); - } - - return message; -}; +export const ERROR_EMPTY_STACKTRACE = ''; +export const ERROR_EMPTY_MESSAGE = 'Unknown Error'; +export const ERROR_DEFAULT_NAME = 'Error'; /** * Will extract the stack from the error, taking the first key found among: @@ -32,13 +16,13 @@ export const getErrorMessage = (error: any | undefined): string => { * generate a stack from this information. */ export const getErrorStackTrace = (error: any | undefined): string => { - let stack = EMPTY_STACK_TRACE; + let stack = ERROR_EMPTY_STACKTRACE; try { if (error === undefined || error === null) { - stack = EMPTY_STACK_TRACE; + stack = ERROR_EMPTY_STACKTRACE; } else if (typeof error === 'string') { - stack = EMPTY_STACK_TRACE; + stack = ERROR_EMPTY_STACKTRACE; } else if (typeof error === 'object') { if ('stacktrace' in error) { stack = String(error.stacktrace); @@ -60,10 +44,52 @@ export const getErrorStackTrace = (error: any | undefined): string => { return stack; }; +export const getErrorMessage = (error: any | undefined): string => { + if (error == null) { + return ERROR_EMPTY_MESSAGE; + } + + // If it's an actual Error (or subclass) + if (error instanceof Error) { + // Prefer .message if defined, otherwise fallback to .toString() + return error.message || error.toString() || ERROR_EMPTY_MESSAGE; + } + + // If it's an object with a message property (not necessarily Error) + if ( + typeof error === 'object' && + 'message' in error && + typeof (error as any).message === 'string' + ) { + return (error as any).message || ERROR_EMPTY_MESSAGE; + } + + // If it’s a primitive (string, number, boolean, symbol) + if ( + typeof error === 'string' || + typeof error === 'number' || + typeof error === 'boolean' || + typeof error === 'symbol' + ) { + return String(error); + } + + // If it has its own toString (not the default Object one) + if ( + typeof error?.toString === 'function' && + error.toString !== Object.prototype.toString + ) { + return error.toString(); + } + + // Fallback + return ERROR_EMPTY_MESSAGE; +}; + export const getErrorName = (error: unknown): string => { try { if (typeof error !== 'object' || error === null) { - return DEFAULT_ERROR_NAME; + return ERROR_DEFAULT_NAME; } if (typeof (error as any).name === 'string') { return (error as any).name; @@ -71,16 +97,5 @@ export const getErrorName = (error: unknown): string => { } catch (e) { // Do nothing } - return DEFAULT_ERROR_NAME; -}; - -export const getErrorContext = (originalContext: any): Record => { - const _debugId = debugId; - if (!_debugId) { - return originalContext; - } - return { - ...originalContext, - '_dd.debug_id': _debugId - }; + return ERROR_DEFAULT_NAME; }; diff --git a/packages/core/src/sdk/AttributesEncoding/helpers.ts b/packages/core/src/sdk/AttributesEncoding/helpers.ts new file mode 100644 index 000000000..3636be9e1 --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/helpers.ts @@ -0,0 +1,134 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { AttributeEncoder, Encodable } from './types'; +import { formatPathForLog, isPlainObject, warn } from './utils'; + +/** + * Recursive in-place encoder: flattens values into `out` dictionary. + * Never applies "context", that's only for the root. + */ +export function encodeAttributesInPlace( + input: unknown, + out: Record, + path: string[], + encoders: AttributeEncoder[] +): void { + const value = applyEncoders(input, encoders); + + // Nullish / primitive + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + out[path.join('.')] = value; + return; + } + + // Arrays + if (Array.isArray(value)) { + const normalize = (x: unknown): Encodable => { + const v = applyEncoders(x, encoders); + + // Primitive / nullish are fine + if ( + v === null || + v === undefined || + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ) { + return v; + } + + if (isPlainObject(v)) { + const nested: Record = {}; + encodeAttributesInPlace(v, nested, [], encoders); + return nested; + } + + if (Array.isArray(v)) { + return v.map(normalize); + } + + // Unsupported + warn( + `Dropped unsupported value in array at '${formatPathForLog( + path + )}': ${String(v)}` + ); + return undefined; + }; + + out[path.join('.')] = value + .map(normalize) + .filter(item => item !== undefined); // drop unsupported + return; + } + + // Plain object + if (isPlainObject(value)) { + for (const [k, v] of Object.entries(value)) { + encodeAttributesInPlace(v, out, [...path, k], encoders); + } + return; + } + + // Unsupported + warn( + `Dropped unsupported value at '${formatPathForLog(path)}': ${String( + value + )}` + ); +} + +/** + * Sanitize unknown input to be JSON-serializable using encoders. + * This does not flatten—it's a pure sanitization pass (used by some encoders). + */ +export function sanitizeForJson( + value: unknown, + encoders: AttributeEncoder[] +): Encodable { + const v = applyEncoders(value, encoders); + + // If still a plain object, sanitize shallowly + if (isPlainObject(v)) { + const out: Record = {}; + for (const [k, val] of Object.entries(v)) { + encodeAttributesInPlace(val, out, [k], encoders); + } + return out; + } + + // If array, sanitize items + if (Array.isArray(v)) { + return v.map(item => sanitizeForJson(item, encoders)); + } + + return v; +} + +export function applyEncoders( + value: unknown, + encoders: AttributeEncoder[] +): Encodable { + for (const enc of encoders) { + try { + if (enc.check(value)) { + return enc.encode(value as never); + } + } catch (err) { + warn(`Encoder error: ${String(err)}`); + return undefined; + } + } + // Not matched by any encoder; leave as-is for the visitor to decide + return value as Encodable; +} diff --git a/packages/core/src/sdk/AttributesEncoding/types.ts b/packages/core/src/sdk/AttributesEncoding/types.ts new file mode 100644 index 000000000..1f136d576 --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/types.ts @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * A value that can safely be encoded as a Datadog Attribute. + */ +export type Encodable = + | string + | number + | boolean + | null + | undefined + | Encodable[] + | { [key: string]: Encodable }; + +export type AttributeKey = string; +export type AttributeValue = Encodable; +export type EncodableAttributes = Record; +export type Attributes = Record; + +/** + * Encoders define how to handle special object types (Date, Error, Map, etc.). + * Each encoder is: + * - check: decides if the value can be handled by this encoder. + * - encode: converts the value into an Encodable. + */ +export interface AttributeEncoder { + check: (value: unknown) => boolean; + encode: (value: T) => Encodable; +} diff --git a/packages/core/src/sdk/AttributesEncoding/utils.ts b/packages/core/src/sdk/AttributesEncoding/utils.ts new file mode 100644 index 000000000..11e3adfdf --- /dev/null +++ b/packages/core/src/sdk/AttributesEncoding/utils.ts @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../SdkVerbosity'; + +export function warn(text: string) { + InternalLog.log(`[ATTRIBUTES] ${text}`, SdkVerbosity.WARN); +} + +export function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === 'object' && (v as any).constructor === Object; +} + +/** + * Utility: format a path array into dot/bracket notation (for logs). + */ +export function formatPathForLog(path: (string | number)[]): string { + return path + .map((segment, index) => { + const s = String(segment); + if (/^\d/.test(s)) { + return `[${s}]`; + } + return index === 0 ? s : `.${s}`; + }) + .join(''); +} diff --git a/packages/core/src/sdk/DatadogInternalBridge/__tests__/DdSdkNativeBridge.test.tsx b/packages/core/src/sdk/DatadogInternalBridge/__tests__/DdSdkNativeBridge.test.tsx index 9a20162fd..3dff4e7ce 100644 --- a/packages/core/src/sdk/DatadogInternalBridge/__tests__/DdSdkNativeBridge.test.tsx +++ b/packages/core/src/sdk/DatadogInternalBridge/__tests__/DdSdkNativeBridge.test.tsx @@ -72,7 +72,7 @@ describe('DdSdkNativeBridge', () => { afterEach(() => { jest.resetModules(); jest.resetAllMocks(); - delete global.RN$Bridgeless; + delete (global as any).RN$Bridgeless; }); describe('new architecture implementation', () => { diff --git a/packages/core/src/sdk/DatadogProvider/Buffer/BoundedBuffer.ts b/packages/core/src/sdk/DatadogProvider/Buffer/BoundedBuffer.ts index dc91a475f..93f9389b0 100644 --- a/packages/core/src/sdk/DatadogProvider/Buffer/BoundedBuffer.ts +++ b/packages/core/src/sdk/DatadogProvider/Buffer/BoundedBuffer.ts @@ -6,8 +6,8 @@ import { InternalLog } from '../../../InternalLog'; import { SdkVerbosity } from '../../../SdkVerbosity'; -import { DdSdk } from '../../../sdk/DdSdk'; -import { getErrorStackTrace } from '../../../utils/errorUtils'; +import { getErrorStackTrace } from '../../AttributesEncoding/errorUtils'; +import { NativeDdSdk } from '../../DdSdkInternal'; import { DatadogBuffer } from './DatadogBuffer'; @@ -206,7 +206,7 @@ export class BoundedBuffer extends DatadogBuffer { private drainTelemetry = () => { Object.values(this.telemetryBuffer).forEach( ({ message, stack, kind, occurrences }) => { - DdSdk.telemetryError( + NativeDdSdk.telemetryError( `${message} happened ${occurrences} times.`, stack, kind diff --git a/packages/core/src/sdk/DatadogProvider/Buffer/__tests__/BoundedBuffer.test.ts b/packages/core/src/sdk/DatadogProvider/Buffer/__tests__/BoundedBuffer.test.ts index 3762da1e5..87fd37798 100644 --- a/packages/core/src/sdk/DatadogProvider/Buffer/__tests__/BoundedBuffer.test.ts +++ b/packages/core/src/sdk/DatadogProvider/Buffer/__tests__/BoundedBuffer.test.ts @@ -5,7 +5,7 @@ */ import { InternalLog } from '../../../../InternalLog'; -import { DdSdk } from '../../../DdSdk'; +import { NativeDdSdk } from '../../../DdSdkInternal'; import { BoundedBuffer } from '../BoundedBuffer'; describe('BoundedBuffer', () => { @@ -126,7 +126,7 @@ describe('BoundedBuffer', () => { await buffer.drain(); expect(callbackWithId).toHaveBeenCalledTimes(1); expect(callbackWithId).toHaveBeenNthCalledWith(1, 'callbackId1'); - expect(DdSdk.telemetryError).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryError).toHaveBeenCalledWith( 'Could not generate enough random numbers happened 2 times.', '', 'RandomIdGenerationError' @@ -146,7 +146,7 @@ describe('BoundedBuffer', () => { await buffer.drain(); expect(fakeCallback).toHaveBeenCalledTimes(3); - expect(DdSdk.telemetryError).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryError).toHaveBeenCalledWith( 'Buffer overflow happened 1 times.', '', 'BufferOverflow' @@ -171,7 +171,7 @@ describe('BoundedBuffer', () => { expect(fakeCallback).toHaveBeenCalledTimes(1); expect(callbackReturningId).not.toHaveBeenCalled(); expect(callbackWithId).not.toHaveBeenCalled(); - expect(DdSdk.telemetryError).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryError).toHaveBeenCalledWith( 'Buffer overflow happened 2 times.', '', 'BufferOverflow' @@ -196,7 +196,7 @@ describe('BoundedBuffer', () => { expect(fakeCallback).toHaveBeenCalledTimes(1); expect(callbackReturningId).toHaveBeenCalledTimes(1); expect(callbackWithId).toHaveBeenCalledTimes(1); - expect(DdSdk.telemetryError).not.toHaveBeenCalled(); + expect(NativeDdSdk.telemetryError).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index c654cd24b..ce95a2579 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -70,6 +70,7 @@ describe('DatadogProvider', () => { }, "appHangThreshold": undefined, "applicationId": "fakeApplicationId", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "MEDIUM", "bundleLogsWithRum": true, diff --git a/packages/core/src/sdk/DdSdk.ts b/packages/core/src/sdk/DdSdk.ts index b086d96bd..a829707e3 100644 --- a/packages/core/src/sdk/DdSdk.ts +++ b/packages/core/src/sdk/DdSdk.ts @@ -3,10 +3,13 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ +import { getGlobalInstance } from '../utils/singletonUtils'; -import type { DdNativeSdkType } from '../nativeModulesTypes'; +import { DdSdkWrapper } from './DdSdkInternal'; +import type { DdSdkType } from './DdSdkInternal'; -// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires -const DdSdk: DdNativeSdkType = require('../specs/NativeDdSdk').default; - -export { DdSdk }; +const CORE_MODULE = 'com.datadog.reactnative.core'; +export const DdSdk = getGlobalInstance( + CORE_MODULE, + () => new DdSdkWrapper() +) as DdSdkType; diff --git a/packages/core/src/sdk/DdSdkInternal.ts b/packages/core/src/sdk/DdSdkInternal.ts new file mode 100644 index 000000000..7af33d2e3 --- /dev/null +++ b/packages/core/src/sdk/DdSdkInternal.ts @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + DdNativeSdkConfiguration, + DdNativeSdkType +} from '../nativeModulesTypes'; + +import type { AttributeEncoder } from './AttributesEncoding/types'; + +// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires +const NativeDdSdk: DdNativeSdkType = require('../specs/NativeDdSdk').default; + +export type DdSdkType = { + readonly attributeEncoders: AttributeEncoder[]; + + /** + * Initializes Datadog's features. + * @param configuration: The configuration to use. + */ + initialize(configuration: DdNativeSdkConfiguration): Promise; +}; + +export class DdSdkWrapper implements DdNativeSdkType { + get attributeEncoders(): AttributeEncoder[] { + return this._attributeEncoders; + } + private _attributeEncoders: AttributeEncoder[] = []; + + initialize(configuration: DdNativeSdkConfiguration): Promise { + this._attributeEncoders = [...configuration.attributeEncoders]; + return NativeDdSdk.initialize(configuration); + } + + getConstants() { + return NativeDdSdk.getConstants(); + } + + setAttributes(attributes: object): Promise { + return NativeDdSdk.setAttributes(attributes); + } + + setUserInfo(user: object): Promise { + return NativeDdSdk.setUserInfo(user); + } + + clearUserInfo(): Promise { + return NativeDdSdk.clearUserInfo(); + } + + addUserExtraInfo(extraInfo: object): Promise { + return NativeDdSdk.addUserExtraInfo(extraInfo); + } + + setTrackingConsent(trackingConsent: string): Promise { + return NativeDdSdk.setTrackingConsent(trackingConsent); + } + + sendTelemetryLog( + message: string, + attributes: object, + config: object + ): Promise { + return NativeDdSdk.sendTelemetryLog(message, attributes, config); + } + + telemetryDebug(message: string): Promise { + return NativeDdSdk.telemetryDebug(message); + } + + telemetryError( + message: string, + stack: string, + kind: string + ): Promise { + return NativeDdSdk.telemetryError(message, stack, kind); + } + + consumeWebviewEvent(message: string): Promise { + return NativeDdSdk.consumeWebviewEvent(message); + } + + clearAllData(): Promise { + return NativeDdSdk.clearAllData(); + } + + addListener(eventType: string): void { + return NativeDdSdk.addListener(eventType); + } + + removeListeners(count: number): void { + return NativeDdSdk.removeListeners(count); + } + + _setAttributeEncodersForTesting( + attributeEncoders: AttributeEncoder[] + ) { + this._attributeEncoders = [...attributeEncoders]; + } +} + +export { NativeDdSdk }; diff --git a/packages/core/src/sdk/EventMappers/EventMapper.ts b/packages/core/src/sdk/EventMappers/EventMapper.ts index 9ca252d72..92b0b1efa 100644 --- a/packages/core/src/sdk/EventMappers/EventMapper.ts +++ b/packages/core/src/sdk/EventMappers/EventMapper.ts @@ -6,9 +6,9 @@ import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; -import { DdSdk } from '../../sdk/DdSdk'; import { AttributesSingleton } from '../AttributesSingleton/AttributesSingleton'; import type { Attributes } from '../AttributesSingleton/types'; +import { NativeDdSdk } from '../DdSdkInternal'; import { UserInfoSingleton } from '../UserInfoSingleton/UserInfoSingleton'; import type { UserInfo } from '../UserInfoSingleton/types'; @@ -87,7 +87,7 @@ export class EventMapper { )}: ${error}`, SdkVerbosity.WARN ); - DdSdk.telemetryDebug('Error while running the event mapper'); + NativeDdSdk.telemetryDebug('Error while running the event mapper'); return this.formatMapperEventForNative(backupEvent, backupEvent); } }; diff --git a/packages/core/src/sdk/EventMappers/__tests__/EventMapper.test.ts b/packages/core/src/sdk/EventMappers/__tests__/EventMapper.test.ts index 780b0a831..93333f7de 100644 --- a/packages/core/src/sdk/EventMappers/__tests__/EventMapper.test.ts +++ b/packages/core/src/sdk/EventMappers/__tests__/EventMapper.test.ts @@ -4,14 +4,14 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdSdk } from '../../DdSdk'; +import { NativeDdSdk } from '../../DdSdkInternal'; import { EventMapper } from '../EventMapper'; describe('EventMapper', () => { it('returns the original log when the event log mapper crashes', () => { const eventMapper = new EventMapper( (event: object) => { - event['badData'] = 'bad data'; + (event as { badData: string })['badData'] = 'bad data'; throw new Error('crashed'); }, (event: object) => event, @@ -26,7 +26,7 @@ describe('EventMapper', () => { ).toEqual({ someData: 'some data' }); - expect(DdSdk.telemetryDebug).toHaveBeenCalledWith( + expect(NativeDdSdk.telemetryDebug).toHaveBeenCalledWith( 'Error while running the event mapper' ); }); diff --git a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts index 716243e86..e48db352c 100644 --- a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts @@ -53,6 +53,7 @@ describe('FileBasedConfiguration', () => { "actionNameAttribute": "action-name-attr", "additionalConfiguration": {}, "applicationId": "fake-app-id", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "MEDIUM", "bundleLogsWithRum": true, @@ -116,6 +117,7 @@ describe('FileBasedConfiguration', () => { "actionNameAttribute": undefined, "additionalConfiguration": {}, "applicationId": "fake-app-id", + "attributeEncoders": [], "batchProcessingLevel": "MEDIUM", "batchSize": "MEDIUM", "bundleLogsWithRum": true, diff --git a/packages/core/src/trace/DdTrace.ts b/packages/core/src/trace/DdTrace.ts index 119716914..8c57a6ed2 100644 --- a/packages/core/src/trace/DdTrace.ts +++ b/packages/core/src/trace/DdTrace.ts @@ -7,12 +7,12 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; import type { DdNativeTraceType } from '../nativeModulesTypes'; +import { encodeAttributes } from '../sdk/AttributesEncoding/attributesEncoding'; import { bufferNativeCallReturningId, bufferNativeCallWithId } from '../sdk/DatadogProvider/Buffer/bufferNativeCall'; import type { DdTraceType } from '../types'; -import { validateContext } from '../utils/argsUtils'; import { getGlobalInstance } from '../utils/singletonUtils'; import { DefaultTimeProvider } from '../utils/time-provider/DefaultTimeProvider'; @@ -33,7 +33,7 @@ class DdTraceWrapper implements DdTraceType { const spanId = bufferNativeCallReturningId(() => this.nativeTrace.startSpan( operation, - validateContext(context), + encodeAttributes(context), timestampMs ) ); @@ -54,7 +54,7 @@ class DdTraceWrapper implements DdTraceType { id => this.nativeTrace.finishSpan( id, - validateContext(context), + encodeAttributes(context), timestampMs ), spanId diff --git a/packages/core/src/trace/__tests__/DdTrace.test.ts b/packages/core/src/trace/__tests__/DdTrace.test.ts index a1567afef..ba7a87d26 100644 --- a/packages/core/src/trace/__tests__/DdTrace.test.ts +++ b/packages/core/src/trace/__tests__/DdTrace.test.ts @@ -52,13 +52,13 @@ describe('DdTrace', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdTrace.startSpan('operation', context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); expect(NativeModules.DdTrace.startSpan).toHaveBeenCalledWith( @@ -104,18 +104,17 @@ describe('DdTrace', () => { }); test('uses empty context with error when context is invalid or null', async () => { - const context: any = 123; + const context: any = Symbol('invalid-context'); await DdTrace.startSpan('operation', context); const spanId = await DdTrace.startSpan('operation', {}); await DdTrace.finishSpan(spanId, context); expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, + 2, expect.anything(), - SdkVerbosity.ERROR + SdkVerbosity.WARN ); - expect(NativeModules.DdTrace.finishSpan).toHaveBeenCalledWith( spanId, {}, diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index bad19d429..dd044f22f 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -5,6 +5,7 @@ */ import type { BatchProcessingLevel } from './DdSdkReactNativeConfiguration'; +import type { AttributeEncoder } from './sdk/AttributesEncoding/types'; declare global { // eslint-disable-next-line no-var, vars-on-top @@ -69,7 +70,8 @@ export class DdSdkConfiguration { readonly resourceTracingSamplingRate: number, readonly trackWatchdogTerminations: boolean | undefined, readonly batchProcessingLevel: BatchProcessingLevel, // eslint-disable-next-line no-empty-function - readonly initialResourceThreshold: number | undefined + readonly initialResourceThreshold: number | undefined, + readonly attributeEncoders: AttributeEncoder[] ) {} } @@ -177,7 +179,6 @@ export type LogEvent = { export type LogEventMapper = (logEvent: LogEvent) => LogEvent | null; // DdRum - export enum ErrorSource { NETWORK = 'NETWORK', SOURCE = 'SOURCE', diff --git a/packages/core/src/utils/__tests__/argsUtils.test.ts b/packages/core/src/utils/__tests__/argsUtils.test.ts deleted file mode 100644 index 1a2f4eacb..000000000 --- a/packages/core/src/utils/__tests__/argsUtils.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -import { InternalLog } from '../../InternalLog'; -import { SdkVerbosity } from '../../SdkVerbosity'; -import { validateContext } from '../argsUtils'; - -jest.mock('../../InternalLog', () => { - return { - InternalLog: { - log: jest.fn() - }, - DATADOG_MESSAGE_PREFIX: 'DATADOG:' - }; -}); - -describe('argsUtils', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('validateContext', () => { - it('returns empty object if context is null', () => { - expect(validateContext(null)).toEqual({}); - expect(validateContext(undefined)).toEqual({}); - }); - - it('returns empty object with error if context is raw type', () => { - expect(validateContext('raw-type')).toEqual({}); - expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, - expect.anything(), - SdkVerbosity.ERROR - ); - }); - - it('nests array inside of new object if context is an array', () => { - const context = [{ a: 1, b: 2 }, 1, true]; - const validatedContext = validateContext(context); - - expect(InternalLog.log).toHaveBeenNthCalledWith( - 1, - expect.anything(), - SdkVerbosity.WARN - ); - - expect(validatedContext).toEqual({ - context - }); - }); - - it('returns unmodified context if it is a valid object', () => { - const context = { - testA: 1, - testB: {} - }; - const validatedContext = validateContext(context); - - expect(validatedContext).toEqual(context); - }); - }); -}); diff --git a/packages/core/src/utils/__tests__/errorUtils.test.ts b/packages/core/src/utils/__tests__/errorUtils.test.ts index b9941d4b4..c89f7a2b7 100644 --- a/packages/core/src/utils/__tests__/errorUtils.test.ts +++ b/packages/core/src/utils/__tests__/errorUtils.test.ts @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import { getErrorName } from '../errorUtils'; +import { getErrorName } from '../../sdk/AttributesEncoding/errorUtils'; describe('errorUtils', () => { describe('getErrorName', () => { diff --git a/packages/core/src/utils/argsUtils.ts b/packages/core/src/utils/argsUtils.ts deleted file mode 100644 index d7a07255f..000000000 --- a/packages/core/src/utils/argsUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -import { InternalLog } from '../InternalLog'; -import { SdkVerbosity } from '../SdkVerbosity'; - -export const validateContext = (context: any) => { - if (!context) { - return {}; - } - - // eslint-disable-next-line eqeqeq - if (context.constructor == Object) { - return context; - } - - if (Array.isArray(context)) { - InternalLog.log( - "The given context is an array, it will be nested in 'context' property inside a new object.", - SdkVerbosity.WARN - ); - return { context }; - } - - InternalLog.log( - `The given context (${context}) is invalid - it must be an object. Context will be empty.`, - SdkVerbosity.ERROR - ); - - return {}; -}; diff --git a/packages/react-native-apollo-client/src/helpers.ts b/packages/react-native-apollo-client/src/helpers.ts index c7f9a435a..9812bd547 100644 --- a/packages/react-native-apollo-client/src/helpers.ts +++ b/packages/react-native-apollo-client/src/helpers.ts @@ -22,7 +22,7 @@ export const getVariables = (operation: Operation): string | null => { try { return JSON.stringify(operation.variables); } catch (e) { - DdSdk?.telemetryError( + (DdSdk as any)?.telemetryError( _getErrorMessage( ErrorCode.GQL_VARIABLE_RETRIEVAL_ERROR, apolloVersion @@ -61,7 +61,7 @@ export const getOperationType = ( })[0] || null ); } catch (e) { - DdSdk?.telemetryError( + (DdSdk as any)?.telemetryError( _getErrorMessage(ErrorCode.GQL_OPERATION_TYPE_ERROR, apolloVersion), _getErrorStack(e), ErrorCode.GQL_OPERATION_TYPE_ERROR