diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogSDKWrapper.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogSDKWrapper.kt index 06151d834..56a15373c 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogSDKWrapper.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogSDKWrapper.kt @@ -89,7 +89,25 @@ internal class DatadogSDKWrapper : DatadogWrapper { override fun clearUserInfo() { Datadog.clearUserInfo() } - + + override fun setAccountInfo( + id: String, + name: String?, + extraInfo: Map + ) { + Datadog.setAccountInfo(id, name, extraInfo) + } + + override fun addAccountExtraInfo( + extraInfo: Map + ) { + Datadog.addAccountExtraInfo(extraInfo) + } + + override fun clearAccountInfo() { + Datadog.clearAccountInfo() + } + override fun addRumGlobalAttribute(key: String, value: Any?) { this.getRumMonitor().addAttribute(key, value) } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogWrapper.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogWrapper.kt index d6395b18b..c72f2faef 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogWrapper.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DatadogWrapper.kt @@ -91,6 +91,33 @@ interface DatadogWrapper { */ fun clearUserInfo() + /** + * Sets the account information. + * + * @param id a unique account identifier (relevant to your business domain) + * @param name (nullable) the account name + * @param extraInfo additional information. An extra information can be + * nested up to 8 levels deep. Keys using more than 8 levels will be sanitized by SDK. + */ + fun setAccountInfo( + id: String, + name: String?, + extraInfo: Map + ) + + /** + * Sets the account information. + * @param extraInfo: The additional information. (To set the id or name please use setAccountInfo). + */ + fun addAccountExtraInfo( + extraInfo: Map + ) + + /** + * Clears the account information. + */ + fun clearAccountInfo() + /** Adds a global attribute. * diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 6688f8061..9264fafa0 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -168,6 +168,47 @@ class DdSdkImplementation( promise.resolve(null) } + /** + * Set the account information. + * @param accountInfo The account object (use builtin attributes: 'id', 'name', and any custom + * attribute inside 'extraInfo'). + */ + fun setAccountInfo(accountInfo: ReadableMap, promise: Promise) { + val accountInfoMap = accountInfo.toHashMap().toMutableMap() + val id = accountInfoMap["id"] as? String + val name = accountInfoMap["name"] as? String + val extraInfo = (accountInfoMap["extraInfo"] as? Map<*, *>)?.filterKeys { it is String } + ?.mapKeys { it.key as String } + ?.mapValues { it.value } ?: emptyMap() + + if (id != null) { + datadog.setAccountInfo(id, name, extraInfo) + } + + promise.resolve(null) + } + + /** + * Sets the account extra information. + * @param accountExtraInfo: The additional information. (To set the id or name please use setAccountInfo). + */ + fun addAccountExtraInfo( + accountExtraInfo: ReadableMap, promise: Promise + ) { + val extraInfoMap = accountExtraInfo.toHashMap().toMutableMap() + + datadog.addAccountExtraInfo(extraInfoMap) + promise.resolve(null) + } + + /** + * Clears the account information. + */ + fun clearAccountInfo(promise: Promise) { + datadog.clearAccountInfo() + promise.resolve(null) + } + /** * Set the tracking consent regarding the data collection. * @param trackingConsent Consent, which can take one of the following values: 'pending', diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt index a9d430081..421812545 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -106,6 +106,32 @@ class DdSdk( implementation.clearUserInfo(promise) } + /** + * Set the account information. + * @param account The account object (use builtin attributes: 'id', 'name', and any custom * attribute inside 'extraInfo'). + */ + @ReactMethod + override fun setAccountInfo(account: ReadableMap, promise: Promise) { + implementation.setAccountInfo(account, promise) + } + + /** + * Sets the account information. + * @param extraAccountInfo: The additional information. (To set the id or name please use setAccountInfo). + */ + @ReactMethod + override fun addAccountExtraInfo(extraInfo: ReadableMap, promise: Promise) { + implementation.addAccountExtraInfo(extraInfo, promise) + } + + /** + * Clears the account information. + */ + @ReactMethod + override fun clearAccountInfo(promise: Promise) { + implementation.clearAccountInfo(promise) + } + /** * Set the tracking consent regarding the data collection. * @param trackingConsent Consent, which can take one of the following values: 'pending', diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt index 958ba521b..ef91ca549 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -132,6 +132,32 @@ class DdSdk( implementation.clearUserInfo(promise) } + /** + * Set the account information. + * @param account The account object (use builtin attributes: 'id', 'name', and any custom * attribute inside 'extraInfo'). + */ + @ReactMethod + fun setAccountInfo(account: ReadableMap, promise: Promise) { + implementation.setAccountInfo(account, promise) + } + + /** + * Sets the account information. + * @param extraAccountInfo: The additional information. (To set the id or name please use setAccountInfo). + */ + @ReactMethod + fun addAccountExtraInfo(extraInfo: ReadableMap, promise: Promise) { + implementation.addAccountExtraInfo(extraInfo, promise) + } + + /** + * Clears the account information. + */ + @ReactMethod + fun clearAccountInfo(promise: Promise) { + implementation.clearAccountInfo(promise) + } + /** * Set the tracking consent regarding the data collection. * @param trackingConsent Consent, which can take one of the following values: 'pending', diff --git a/packages/core/ios/Sources/DdSdk.mm b/packages/core/ios/Sources/DdSdk.mm index 7129d7af1..489210503 100644 --- a/packages/core/ios/Sources/DdSdk.mm +++ b/packages/core/ios/Sources/DdSdk.mm @@ -79,6 +79,26 @@ + (void)initFromNative { [self clearUserInfo:resolve reject:reject]; } +RCT_REMAP_METHOD(setAccountInfo, withAccountInfo:(NSDictionary*)accountInfo + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setAccountInfo:accountInfo resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(addAccountExtraInfo, withAccountExtraInfo:(NSDictionary*)extraInfo + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self addAccountExtraInfo:extraInfo resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(clearAccountInfo:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self clearAccountInfo:resolve reject:reject]; +} + RCT_REMAP_METHOD(setTrackingConsent, withTrackingConsent:(NSString*)trackingConsent withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) @@ -189,6 +209,18 @@ -(void)addUserExtraInfo:(NSDictionary *)extraInfo resolve:(RCTPromiseResolveBloc [self.ddSdkImplementation addUserExtraInfoWithExtraInfo:extraInfo resolve:resolve reject:reject]; } +- (void)setAccountInfo:(NSDictionary *)accountInfo resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation setAccountInfoWithAccountInfo:accountInfo resolve:resolve reject:reject]; +} + +- (void)clearAccountInfo:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation clearAccountInfoWithResolve:resolve reject:reject]; +} + +-(void)addAccountExtraInfo:(NSDictionary *)extraInfo resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation addAccountExtraInfoWithExtraInfo:extraInfo resolve:resolve reject:reject]; +} + - (void)sendTelemetryLog:(NSString *)message attributes:(NSDictionary *)attributes config:(NSDictionary *)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.ddSdkImplementation sendTelemetryLogWithMessage:message attributes:attributes config:config resolve:resolve reject:reject]; } diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 87fe91729..9ea820858 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -153,6 +153,44 @@ public class DdSdkImplementation: NSObject { resolve(nil) } + @objc + public func setAccountInfo( + accountInfo: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let castedAccountInfo = castAttributesToSwift(accountInfo) + let id = castedAccountInfo["id"] as? String + let name = castedAccountInfo["name"] as? String + var extraInfo: [AttributeKey: AttributeValue] = [:] + + if let extraInfoEncodable = castedAccountInfo["extraInfo"] as? AnyEncodable, + let extraInfoDict = extraInfoEncodable.value as? [String: Any] + { + extraInfo = castAttributesToSwift(extraInfoDict) + } + + if let validId = id { + Datadog.setAccountInfo(id: validId, name: name, extraInfo: extraInfo) + } + + resolve(nil) + } + + @objc + public func addAccountExtraInfo( + extraInfo: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let castedExtraInfo = castAttributesToSwift(extraInfo) + + Datadog.addAccountExtraInfo(castedExtraInfo) + resolve(nil) + } + + @objc + public func clearAccountInfo(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + Datadog.clearAccountInfo() + resolve(nil) + } + @objc public func setTrackingConsent( trackingConsent: NSString, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index 0dc9ec138..9f5ee2c41 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -36,6 +36,15 @@ module.exports = { clearUserInfo: jest .fn() .mockImplementation(() => new Promise(resolve => resolve())), + setAccountInfo: jest + .fn() + .mockImplementation(() => new Promise(resolve => resolve())), + addAccountExtraInfo: jest + .fn() + .mockImplementation(() => new Promise(resolve => resolve())), + clearAccountInfo: jest + .fn() + .mockImplementation(() => new Promise(resolve => resolve())), addAttribute: jest .fn() .mockImplementation(() => new Promise(resolve => resolve())), diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index b1ea4f4c1..e07ba4e34 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -30,6 +30,7 @@ import { DdRumErrorTracking } from './rum/instrumentation/DdRumErrorTracking'; import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking'; import { DdRumUserInteractionTracking } from './rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { DdRumResourceTracking } from './rum/instrumentation/resourceTracking/DdRumResourceTracking'; +import { AccountInfoSingleton } from './sdk/AccountInfoSingleton/AccountInfoSingleton'; import { AttributesSingleton } from './sdk/AttributesSingleton/AttributesSingleton'; import type { Attributes } from './sdk/AttributesSingleton/types'; import { registerNativeBridge } from './sdk/DatadogInternalBridge/DdSdkInternalNativeBridge'; @@ -299,6 +300,71 @@ export class DdSdkReactNative { UserInfoSingleton.getInstance().setUserInfo(updatedUserInfo); }; + /** + * Sets the account information. + * @param id: A mandatory unique account identifier (relevant to your business domain). + * @param name: The account name. + * @param extraInfo: Additional information. + * @returns a Promise. + */ + static setAccountInfo = async (accountInfo: { + id: string; + name?: string; + extraInfo?: Record; + }): Promise => { + InternalLog.log( + `Setting account ${JSON.stringify(accountInfo)}`, + SdkVerbosity.DEBUG + ); + + await DdSdk.setAccountInfo(accountInfo); + AccountInfoSingleton.getInstance().setAccountInfo(accountInfo); + }; + + /** + * Clears the account information. + * @returns a Promise. + */ + static clearAccountInfo = async (): Promise => { + InternalLog.log('Clearing account info', SdkVerbosity.DEBUG); + await DdSdk.clearAccountInfo(); + AccountInfoSingleton.getInstance().clearAccountInfo(); + }; + + /** + * Set the account information. + * @param extraAccountInfo: The additional information. (To set the id or name please use setAccountInfo). + * @returns a Promise. + */ + static addAccountExtraInfo = async ( + extraAccountInfo: Record + ): Promise => { + InternalLog.log( + `Adding extra account info ${JSON.stringify(extraAccountInfo)}`, + SdkVerbosity.DEBUG + ); + + const accountInfo = AccountInfoSingleton.getInstance().getAccountInfo(); + if (!accountInfo) { + InternalLog.log( + 'Skipped adding Account Extra Info: Account Info is currently undefined. An account ID must be set before adding extra info. Please call setAccountInfo() first.', + SdkVerbosity.WARN + ); + + return; + } + + const extraInfo = { + ...accountInfo.extraInfo, + ...extraAccountInfo + }; + + await DdSdk.addAccountExtraInfo(extraInfo); + AccountInfoSingleton.getInstance().addAccountExtraInfo( + extraAccountInfo + ); + }; + /** * Set the tracking consent regarding the data collection. * @param trackingConsent: One of TrackingConsent values. diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index e5deb8146..1f3703e63 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -20,12 +20,19 @@ import { getGlobalInstance } from '../utils/singletonUtils'; import { DefaultTimeProvider } from '../utils/time-provider/DefaultTimeProvider'; import type { TimeProvider } from '../utils/time-provider/TimeProvider'; -import { generateActionEventMapper } from './eventMappers/actionEventMapper'; import type { ActionEventMapper } from './eventMappers/actionEventMapper'; -import { generateErrorEventMapper } from './eventMappers/errorEventMapper'; +import { generateActionEventMapper } from './eventMappers/actionEventMapper'; import type { ErrorEventMapper } from './eventMappers/errorEventMapper'; -import { generateResourceEventMapper } from './eventMappers/resourceEventMapper'; +import { generateErrorEventMapper } from './eventMappers/errorEventMapper'; import type { ResourceEventMapper } from './eventMappers/resourceEventMapper'; +import { generateResourceEventMapper } from './eventMappers/resourceEventMapper'; +import { + clearCachedSessionId, + getCachedAccountId, + getCachedSessionId, + getCachedUserId, + setCachedSessionId +} from './helper'; import type { DatadogTracingContext } from './instrumentation/resourceTracking/distributedTracing/DatadogTracingContext'; import { DatadogTracingIdentifier } from './instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier'; import { TracingIdentifier } from './instrumentation/resourceTracking/distributedTracing/TracingIdentifier'; @@ -33,16 +40,12 @@ import { getTracingContext, getTracingContextForPropagators } from './instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders'; -import { - getCachedSessionId, - setCachedSessionId -} from './sessionId/sessionIdHelper'; import type { DdRumType, - RumActionType, - ResourceKind, FirstPartyHost, - PropagatorType + PropagatorType, + ResourceKind, + RumActionType } from './types'; const RUM_MODULE = 'com.datadog.reactnative.rum'; @@ -343,6 +346,7 @@ class DdRumWrapper implements DdRumType { stopSession = (): Promise => { InternalLog.log('Stopping RUM Session', SdkVerbosity.DEBUG); + clearCachedSessionId(); return bufferVoidNativeCall(() => this.nativeRum.stopSession()); }; @@ -381,7 +385,9 @@ class DdRumWrapper implements DdRumType { url, tracingSamplingRate, firstPartyHosts, - getCachedSessionId() + getCachedSessionId(), + getCachedUserId(), + getCachedAccountId() ); }; @@ -392,7 +398,9 @@ class DdRumWrapper implements DdRumType { return getTracingContextForPropagators( propagators, tracingSamplingRate, - getCachedSessionId() + getCachedSessionId(), + getCachedUserId(), + getCachedAccountId() ); }; diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 41b873fe9..e4318322b 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -17,11 +17,11 @@ import { DdRum } from '../DdRum'; import type { ActionEventMapper } from '../eventMappers/actionEventMapper'; import type { ErrorEventMapper } from '../eventMappers/errorEventMapper'; import type { ResourceEventMapper } from '../eventMappers/resourceEventMapper'; +import { setCachedSessionId } from '../helper'; import { DatadogTracingContext } from '../instrumentation/resourceTracking/distributedTracing/DatadogTracingContext'; import { DatadogTracingIdentifier } from '../instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier'; import { TracingIdFormat } from '../instrumentation/resourceTracking/distributedTracing/TracingIdentifier'; import { TracingIdentifierUtils } from '../instrumentation/resourceTracking/distributedTracing/__tests__/__utils__/TracingIdentifierUtils'; -import { setCachedSessionId } from '../sessionId/sessionIdHelper'; import type { FirstPartyHost } from '../types'; import { PropagatorType, RumActionType } from '../types'; diff --git a/packages/core/src/rum/helper.ts b/packages/core/src/rum/helper.ts new file mode 100644 index 000000000..a153b79b5 --- /dev/null +++ b/packages/core/src/rum/helper.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ +let _cachedSessionId: string | undefined; +let _cachedUserId: string | undefined; +let _cachedAccountId: string | undefined; + +export const getCachedSessionId = () => { + return _cachedSessionId; +}; + +export const setCachedSessionId = (sessionId: string) => { + _cachedSessionId = sessionId; +}; + +export const clearCachedSessionId = () => { + _cachedSessionId = undefined; +}; + +export const getCachedUserId = () => { + return _cachedUserId; +}; + +export const setCachedUserId = (userId: string) => { + _cachedUserId = userId; +}; + +export const getCachedAccountId = () => { + return _cachedAccountId; +}; + +export const setCachedAccountId = (accountId: string) => { + _cachedAccountId = accountId; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts new file mode 100644 index 000000000..dbb18b89b --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts @@ -0,0 +1,14 @@ +/* + * 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 { isDatadogCustomHeader } from '../headers'; + +describe('headers', () => { + describe('isDatadogCustomHeader', () => { + it('returns false for non-custom headers', () => { + expect(isDatadogCustomHeader('non-custom-header')).toBeFalsy(); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracing.tsx b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracing.tsx index 9c4fcbff7..2ebb98233 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracing.tsx +++ b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracing.tsx @@ -26,6 +26,9 @@ export type DdRumResourceTracingAttributes = rulePsr: number; propagatorTypes: PropagatorType[]; rumSessionId?: string; + userId?: string; + accountId?: string; + baggageHeaders?: Set; } | { tracingStrategy: 'DISCARD'; @@ -43,12 +46,16 @@ export const getTracingAttributes = ({ hostname, firstPartyHostsRegexMap, tracingSamplingRate, - rumSessionId + rumSessionId, + userId, + accountId }: { hostname: Hostname | null; firstPartyHostsRegexMap: RegexMap; tracingSamplingRate: number; rumSessionId?: string; + userId?: string; + accountId?: string; }): DdRumResourceTracingAttributes => { if (hostname === null) { return DISCARDED_TRACE_ATTRIBUTES; @@ -61,7 +68,9 @@ export const getTracingAttributes = ({ return generateTracingAttributesWithSampling( tracingSamplingRate, propagatorsForHost, - rumSessionId + rumSessionId, + userId, + accountId ); } return DISCARDED_TRACE_ATTRIBUTES; @@ -70,7 +79,9 @@ export const getTracingAttributes = ({ export const generateTracingAttributesWithSampling = ( tracingSamplingRate: number, propagatorTypes: PropagatorType[], - rumSessionId?: string + rumSessionId?: string, + userId?: string, + accountId?: string ): DdRumResourceTracingAttributes => { if (!propagatorTypes || propagatorTypes.length === 0) { return DISCARDED_TRACE_ATTRIBUTES; @@ -93,7 +104,9 @@ export const generateTracingAttributesWithSampling = ( tracingStrategy: 'KEEP', rulePsr: tracingSamplingRate / 100, propagatorTypes, - rumSessionId + rumSessionId, + userId, + accountId }; return tracingAttributes; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts index f4decf2ac..033775a17 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts @@ -1,6 +1,5 @@ /* - * 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/). + * 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. */ @@ -29,6 +28,8 @@ export const PARENT_ID_HEADER_KEY = 'x-datadog-parent-id'; export const TAGS_HEADER_KEY = 'x-datadog-tags'; export const DD_TRACE_ID_TAG = '_dd.p.tid'; export const DD_RUM_SESSION_ID_TAG = 'session.id'; +export const DD_RUM_USER_ID_TAG = 'user.id'; +export const DD_RUM_ACCOUNT_ID_TAG = 'account.id'; /** * OTel headers @@ -48,9 +49,12 @@ export const getTracingHeadersFromAttributes = ( if (tracingAttributes.tracingStrategy === 'DISCARD') { return headers; } + + let hasDatadogOrW3CPropagator = false; tracingAttributes.propagatorTypes.forEach(propagator => { switch (propagator) { case PropagatorType.DATADOG: { + hasDatadogOrW3CPropagator = true; headers.push( { header: ORIGIN_HEADER_KEY, @@ -82,6 +86,7 @@ export const getTracingHeadersFromAttributes = ( break; } case PropagatorType.TRACECONTEXT: { + hasDatadogOrW3CPropagator = true; const isSampled = tracingAttributes.samplingPriorityHeader === '1'; headers.push( @@ -137,13 +142,30 @@ export const getTracingHeadersFromAttributes = ( ); } } + }); + + if (hasDatadogOrW3CPropagator) { if (tracingAttributes.rumSessionId) { headers.push({ header: BAGGAGE_HEADER_KEY, value: `${DD_RUM_SESSION_ID_TAG}=${tracingAttributes.rumSessionId}` }); } - }); + + if (tracingAttributes.userId) { + headers.push({ + header: BAGGAGE_HEADER_KEY, + value: `${DD_RUM_USER_ID_TAG}=${tracingAttributes.userId}` + }); + } + + if (tracingAttributes.accountId) { + headers.push({ + header: BAGGAGE_HEADER_KEY, + value: `${DD_RUM_ACCOUNT_ID_TAG}=${tracingAttributes.accountId}` + }); + } + } return headers; }; @@ -152,7 +174,9 @@ export const getTracingContext = ( url: string, tracingSamplingRate: number, firstPartyHosts: FirstPartyHost[], - rumSessionId?: string + rumSessionId?: string, + userId?: string, + accountId?: string ): DatadogTracingContext => { const hostname = URLHostParser(url); const firstPartyHostsRegexMap = firstPartyHostsRegexMapBuilder( @@ -162,7 +186,9 @@ export const getTracingContext = ( hostname, firstPartyHostsRegexMap, tracingSamplingRate, - rumSessionId + rumSessionId, + userId, + accountId }); return getTracingContextForAttributes( @@ -174,13 +200,17 @@ export const getTracingContext = ( export const getTracingContextForPropagators = ( propagators: PropagatorType[], tracingSamplingRate: number, - rumSessionId?: string + rumSessionId?: string, + userId?: string, + accountId?: string ): DatadogTracingContext => { return getTracingContextForAttributes( generateTracingAttributesWithSampling( tracingSamplingRate, propagators, - rumSessionId + rumSessionId, + userId, + accountId ), tracingSamplingRate ); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts index b7d7dfa10..78c0f3b88 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts @@ -4,11 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ +import { isDatadogCustomHeader } from '../../headers'; import { DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, - DATADOG_GRAPH_QL_VARIABLES_HEADER, - isDatadogCustomHeader + DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../graphqlHeaders'; describe('GraphQL custom headers', () => { @@ -19,10 +19,4 @@ describe('GraphQL custom headers', () => { ])('%s matches the custom header pattern', header => { expect(isDatadogCustomHeader(header)).toBeTruthy(); }); - - describe('isDatadogCustomHeader', () => { - it('returns false for non-custom headers', () => { - expect(isDatadogCustomHeader('non-custom-header')).toBeFalsy(); - }); - }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts index 87c79e65e..730b1c468 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts @@ -1,15 +1,10 @@ +import { DATADOG_CUSTOM_HEADER_PREFIX } from '../headers'; + /* * 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. */ - -const DATADOG_CUSTOM_HEADER_PREFIX = '_dd-custom-header'; - export const DATADOG_GRAPH_QL_OPERATION_NAME_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-operation-name`; export const DATADOG_GRAPH_QL_VARIABLES_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-variables`; export const DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-operation-type`; - -export const isDatadogCustomHeader = (header: string) => { - return header.match(new RegExp(`^${DATADOG_CUSTOM_HEADER_PREFIX}`)); -}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headers.ts b/packages/core/src/rum/instrumentation/resourceTracking/headers.ts new file mode 100644 index 000000000..6ecdd37ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headers.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export const DATADOG_CUSTOM_HEADER_PREFIX = '_dd-custom-header'; +export const DATADOG_BAGGAGE_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-baggage`; + +export const isDatadogCustomHeader = (header: string) => { + return header.match(new RegExp(`^${DATADOG_CUSTOM_HEADER_PREFIX}`)); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index e81c8014f..723ace5ed 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -5,21 +5,29 @@ */ import { Timer } from '../../../../../utils/Timer'; -import { getCachedSessionId } from '../../../../sessionId/sessionIdHelper'; -import { getTracingHeadersFromAttributes } from '../../distributedTracing/distributedTracingHeaders'; +import { + getCachedAccountId, + getCachedSessionId, + getCachedUserId +} from '../../../../helper'; +import { + BAGGAGE_HEADER_KEY, + getTracingHeadersFromAttributes +} from '../../distributedTracing/distributedTracingHeaders'; import type { DdRumResourceTracingAttributes } from '../../distributedTracing/distributedTracing'; import { getTracingAttributes } from '../../distributedTracing/distributedTracing'; import { DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, - DATADOG_GRAPH_QL_VARIABLES_HEADER, - isDatadogCustomHeader + DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../graphql/graphqlHeaders'; +import { DATADOG_BAGGAGE_HEADER, isDatadogCustomHeader } from '../../headers'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import { RequestProxy } from '../interfaces/RequestProxy'; import type { ResourceReporter } from './DatadogRumResource/ResourceReporter'; import { URLHostParser } from './URLHostParser'; +import { formatBaggageHeader } from './baggageHeaderUtils'; import { calculateResponseSize } from './responseSize'; const RESPONSE_START_LABEL = 'response_start'; @@ -39,6 +47,7 @@ interface DdRumXhrContext { reported: boolean; timer: Timer; tracingAttributes: DdRumResourceTracingAttributes; + baggageHeaderEntries: Set; } interface XHRProxyProviders { @@ -110,8 +119,11 @@ const proxyOpen = ( hostname, firstPartyHostsRegexMap, tracingSamplingRate, - rumSessionId: getCachedSessionId() - }) + rumSessionId: getCachedSessionId(), + userId: getCachedUserId(), + accountId: getCachedAccountId() + }), + baggageHeaderEntries: new Set() }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -127,12 +139,22 @@ const proxySend = (providers: XHRProxyProviders): void => { // keep track of start time this._datadog_xhr.timer.start(); + // Tracing Headers const tracingHeaders = getTracingHeadersFromAttributes( this._datadog_xhr.tracingAttributes ); + tracingHeaders.forEach(({ header, value }) => { this.setRequestHeader(header, value); }); + + // Join all baggage header entries + const baggageHeader = formatBaggageHeader( + this._datadog_xhr.baggageHeaderEntries + ); + if (baggageHeader) { + this.setRequestHeader(DATADOG_BAGGAGE_HEADER, baggageHeader); + } } proxyOnReadyStateChange(this, providers); @@ -211,22 +233,37 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { header: string, value: string ) { - if (isDatadogCustomHeader(header)) { - if (header === DATADOG_GRAPH_QL_OPERATION_NAME_HEADER) { - this._datadog_xhr.graphql.operationName = value; - return; - } - if (header === DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) { - this._datadog_xhr.graphql.operationType = value; - return; - } - if (header === DATADOG_GRAPH_QL_VARIABLES_HEADER) { - this._datadog_xhr.graphql.variables = value; - return; + const key = header.toLowerCase(); + if (isDatadogCustomHeader(key)) { + switch (key) { + case DATADOG_GRAPH_QL_OPERATION_NAME_HEADER: + this._datadog_xhr.graphql.operationName = value; + break; + case DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER: + this._datadog_xhr.graphql.operationType = value; + break; + case DATADOG_GRAPH_QL_VARIABLES_HEADER: + this._datadog_xhr.graphql.variables = value; + break; + case DATADOG_BAGGAGE_HEADER: + // Apply Baggage Header only if pre-processed by Datadog + return originalXhrSetRequestHeader.apply(this, [ + BAGGAGE_HEADER_KEY, + value + ]); + default: + return originalXhrSetRequestHeader.apply( + this, + // eslint-disable-next-line prefer-rest-params + arguments as any + ); } + } else if (key === BAGGAGE_HEADER_KEY) { + // Intercept User Baggage Header entries to apply them later + this._datadog_xhr.baggageHeaderEntries?.add(value); + } else { + // eslint-disable-next-line prefer-rest-params + return originalXhrSetRequestHeader.apply(this, arguments as any); } - - // eslint-disable-next-line prefer-rest-params - return originalXhrSetRequestHeader.apply(this, arguments as any); }; }; 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..a18825771 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 @@ -11,7 +11,11 @@ import { InternalLog } from '../../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../../SdkVerbosity'; import { BufferSingleton } from '../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; import { DdRum } from '../../../../../DdRum'; -import { setCachedSessionId } from '../../../../../sessionId/sessionIdHelper'; +import { + setCachedSessionId, + setCachedUserId, + setCachedAccountId +} from '../../../../../helper'; import { PropagatorType } from '../../../../../types'; import { XMLHttpRequestMock } from '../../../__tests__/__utils__/XMLHttpRequestMock'; import { TracingIdentifierUtils } from '../../../distributedTracing/__tests__/__utils__/TracingIdentifierUtils'; @@ -90,6 +94,10 @@ afterEach(() => { (Date.now as jest.MockedFunction).mockClear(); jest.spyOn(global.Math, 'random').mockRestore(); DdRum.unregisterResourceEventMapper(); + + setCachedSessionId(undefined as any); + setCachedUserId(undefined as any); + setCachedAccountId(undefined as any); }); describe('XHRProxy', () => { @@ -839,6 +847,93 @@ describe('XHRProxy', () => { // THEN expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined(); }); + + it('does not add rum session id to baggage headers when propagator type is not datadog or w3c', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://example.com'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([ + { + match: 'api.example.com', + propagatorTypes: [ + PropagatorType.DATADOG, + PropagatorType.TRACECONTEXT + ] + }, + { + match: 'example.com', // <-- no datadog or tracecontext here + propagatorTypes: [ + PropagatorType.B3, + PropagatorType.B3MULTI + ] + } + ]) + }); + + setCachedSessionId('TEST-SESSION-ID'); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined(); + }); + + it('rum session id does not overwrite existing baggage headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com:443/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([ + { + match: 'api.example.com', + propagatorTypes: [ + PropagatorType.DATADOG, + PropagatorType.TRACECONTEXT + ] + }, + { + match: 'example.com', + propagatorTypes: [ + PropagatorType.B3, + PropagatorType.B3MULTI + ] + } + ]) + }); + + setCachedSessionId('TEST-SESSION-ID'); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('baggage', 'existing.key=existing-value'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).not.toBeUndefined(); + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toContain( + 'existing.key=existing-value' + ); + + const values = xhr.requestHeaders[BAGGAGE_HEADER_KEY].split( + ',' + ).sort(); + + expect(values[0]).toBe('existing.key=existing-value'); + expect(values[1]).toBe('session.id=TEST-SESSION-ID'); + }); }); describe('DdRum.startResource calls', () => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts new file mode 100644 index 000000000..eee59838f --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts @@ -0,0 +1,117 @@ +import { InternalLog } from '../../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../../SdkVerbosity'; +import { formatBaggageHeader } from '../baggageHeaderUtils'; + +describe('formatBaggageHeader', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(InternalLog, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should format simple key=value entries correctly', () => { + const entries = new Set(['userId=alice', 'isProduction=false']); + const result = formatBaggageHeader(entries); + expect(result).toBe('userId=alice,isProduction=false'); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('should percent-encode spaces and non-ASCII characters in values', () => { + const entries = new Set(['user=Amélie', 'region=us east']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=Am%C3%A9lie,region=us%20east'); + }); + + it('should support properties with and without values', () => { + const entries = new Set(['traceId=abc123;sampled=true;debug']); + const result = formatBaggageHeader(entries); + expect(result).toBe('traceId=abc123;sampled=true;debug'); + }); + + it('should trim whitespace around keys, values, and properties', () => { + const entries = new Set([' foo = bar ; p1 = one ; p2 ']); + const result = formatBaggageHeader(entries); + expect(result).toBe('foo=bar;p1=one;p2'); + }); + + it('should skip invalid entries without crashing', () => { + const entries = new Set(['valid=ok', 'invalidEntry']); + const result = formatBaggageHeader(entries); + expect(result).toBe('valid=ok'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Dropped invalid baggage header entry'), + SdkVerbosity.WARN + ); + }); + + it('should skip entries with invalid key (non-token)', () => { + const entries = new Set(['in valid=value', 'user=ok']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=ok'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('key not compliant'), + SdkVerbosity.WARN + ); + }); + + it('should skip invalid properties (bad property key)', () => { + const entries = new Set(['user=ok;invalid key=value;good=yes']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=ok;good=yes'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('property key not compliant'), + SdkVerbosity.WARN + ); + }); + + it('should log warning when too many members (>64)', () => { + const entries = new Set(); + for (let i = 0; i < 70; i++) { + entries.add(`k${i}=v${i}`); + } + const result = formatBaggageHeader(entries); + expect(result?.startsWith('k0=v0')).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Too many baggage members'), + SdkVerbosity.WARN + ); + }); + + it('should log warning when header exceeds byte limit', () => { + const bigValue = 'x'.repeat(9000); + const entries = new Set([`large=${bigValue}`]); + const result = formatBaggageHeader(entries); + expect(result).toContain('large='); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Baggage header too large'), + SdkVerbosity.WARN + ); + }); + + it('should return null if all entries are invalid', () => { + const entries = new Set(['badEntry', 'stillBad']); + const result = formatBaggageHeader(entries); + expect(result).toBeNull(); + }); + + it('should preserve insertion order', () => { + const entries = new Set(['first=1', 'second=2', 'third=3']); + const result = formatBaggageHeader(entries); + expect(result).toBe('first=1,second=2,third=3'); + }); + + it('should trim keys and values', () => { + const entries = new Set([ + 'traceId=abc123;sampled=true;debug', + 'test1 = this is a test' + ]); + const result = formatBaggageHeader(entries); + expect(result).toBe( + 'traceId=abc123;sampled=true;debug,test1=this%20is%20a%20test' + ); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts new file mode 100644 index 000000000..7094ec4e8 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts @@ -0,0 +1,172 @@ +/* + * 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'; + +// The resulting baggage-string should contain 64 list-members or less (https://www.w3.org/TR/baggage/#limits) +const MAX_MEMBERS = 64; + +// The resulting baggage-string should be of size 8192 bytes or less (https://www.w3.org/TR/baggage/#limits) +const MAX_BYTES = 8192; + +// The keys must follow RFC 7230 token grammar (https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6) +const TOKEN_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * Lazy property for {@link getBaggageHeaderSafeChars}. + */ +let baggageHeaderSafeChars: Set | undefined; + +/** + * Transform a Set of baggage entries (strings like "key=value;prop1=foo;prop2") + * into a compliant baggage header value per W3C Baggage spec. + */ +export function formatBaggageHeader(entries: Set): string | null { + const formattedParts: string[] = []; + + for (const rawEntry of entries) { + if (!rawEntry.includes('=')) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - expected format "key=value".', + SdkVerbosity.WARN + ); + continue; + } + + // Split first key=value from properties (properties are after first ';') + const [mainPart, ...rawProperties] = rawEntry.split(';'); + const idx = mainPart.indexOf('='); + if (idx <= 0) { + InternalLog.log( + "XHRProxy: Dropped invalid baggage header entry - no '=' or empty key", + SdkVerbosity.WARN + ); + continue; + } + + const rawKey = mainPart.slice(0, idx).trim(); + const rawValue = mainPart.slice(idx + 1).trim(); + + if (!TOKEN_REGEX.test(rawKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + + const encodedValue = encodeValue(rawValue); + + // Handle properties + const properties: string[] = []; + for (const rawProperty of rawProperties) { + const trimmed = rawProperty.trim(); + if (!trimmed) { + continue; + } + + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) { + // Property with no value (key1=value1;prop1; ... ) + const propKey = trimmed.trim(); + if (!TOKEN_REGEX.test(propKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - property key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + properties.push(propKey); + } else { + // Property in key-value format (key1=value1;prop1=propValue1; ... ) + const propKey = trimmed.slice(0, eqIdx).trim(); + const propVal = trimmed.slice(eqIdx + 1).trim(); + if (!TOKEN_REGEX.test(propKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - key-value property key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + properties.push(`${propKey}=${encodeValue(propVal)}`); + } + } + + const joinedProps = properties.length ? `;${properties.join(';')}` : ''; + formattedParts.push(`${rawKey}=${encodedValue}${joinedProps}`); + } + + if (formattedParts.length > MAX_MEMBERS) { + InternalLog.log( + `XHRProxy: Too many baggage members: ${formattedParts.length} > ${MAX_MEMBERS} - entries may be dropped (https://www.w3.org/TR/baggage/#limits)`, + SdkVerbosity.WARN + ); + } else if (formattedParts.length === 0) { + return null; + } + + const headerValue = formattedParts.join(','); + const byteLength = Buffer.byteLength(headerValue, 'utf8'); + + if (byteLength > MAX_BYTES) { + InternalLog.log( + `Baggage header too large: ${byteLength} bytes > ${MAX_BYTES} - entries may be dropped (https://www.w3.org/TR/baggage/#limits)`, + SdkVerbosity.WARN + ); + } + + return headerValue; +} + +/** + * Returns a set of valid baggage header characters. + */ +function getBaggageHeaderSafeChars(): Set { + if (baggageHeaderSafeChars) { + return baggageHeaderSafeChars; + } + + const safeChars = new Set(); + for (let c = 0x21; c <= 0x7e; c++) { + if ( + c === 0x22 || + c === 0x2c || + c === 0x3b || + c === 0x5c || + c === 0x20 + ) { + continue; + } + safeChars.add(String.fromCharCode(c)); + } + + baggageHeaderSafeChars = safeChars; + + return safeChars; +} + +/* + * Percent-encode all characters outside baggage-octet range. + */ +function encodeValue(raw: string): string { + const safeChars = getBaggageHeaderSafeChars(); + let result = ''; + for (const ch of Array.from(raw)) { + if (safeChars.has(ch)) { + result += ch; + } else { + const utf8Bytes = Buffer.from(ch, 'utf8'); + for (const value of utf8Bytes) { + result += `%${value + .toString(16) + .toUpperCase() + .padStart(2, '0')}`; + } + } + } + return result; +} diff --git a/packages/core/src/sdk/AccountInfoSingleton/AccountInfoSingleton.ts b/packages/core/src/sdk/AccountInfoSingleton/AccountInfoSingleton.ts new file mode 100644 index 000000000..439c1493a --- /dev/null +++ b/packages/core/src/sdk/AccountInfoSingleton/AccountInfoSingleton.ts @@ -0,0 +1,49 @@ +/* + * 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 { setCachedAccountId } from '../../rum/helper'; + +import type { AccountInfo } from './types'; + +class AccountInfoProvider { + private accountInfo: AccountInfo | undefined = undefined; + + setAccountInfo = (accountInfo: AccountInfo) => { + this.accountInfo = accountInfo; + setCachedAccountId(this.accountInfo.id); + }; + + addAccountExtraInfo = (extraInfo: AccountInfo['extraInfo']) => { + if (!this.accountInfo) { + return; + } + + this.accountInfo.extraInfo = { + ...this.accountInfo.extraInfo, + ...extraInfo + }; + }; + + getAccountInfo = (): AccountInfo | undefined => { + return this.accountInfo; + }; + + clearAccountInfo = () => { + this.accountInfo = undefined; + }; +} + +export class AccountInfoSingleton { + private static accountInfoProvider = new AccountInfoProvider(); + + static getInstance = (): AccountInfoProvider => { + return AccountInfoSingleton.accountInfoProvider; + }; + + static reset = () => { + AccountInfoSingleton.accountInfoProvider = new AccountInfoProvider(); + }; +} diff --git a/packages/core/src/sdk/AccountInfoSingleton/__tests__/AccountInfoSingleton.test.ts b/packages/core/src/sdk/AccountInfoSingleton/__tests__/AccountInfoSingleton.test.ts new file mode 100644 index 000000000..9af387619 --- /dev/null +++ b/packages/core/src/sdk/AccountInfoSingleton/__tests__/AccountInfoSingleton.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { AccountInfoSingleton } from '../AccountInfoSingleton'; + +describe('AccountInfoSingleton', () => { + beforeEach(() => { + AccountInfoSingleton.reset(); + }); + + it('returns undefined by default', () => { + expect( + AccountInfoSingleton.getInstance().getAccountInfo() + ).toBeUndefined(); + }); + + it('stores and returns account info after `setAccountInfo`', () => { + const info = { + id: 'test', + name: 'test user', + extraInfo: { premium: true } + }; + + AccountInfoSingleton.getInstance().setAccountInfo(info); + + expect(AccountInfoSingleton.getInstance().getAccountInfo()).toEqual( + info + ); + }); + + it('adds extra account info with `addAccountExtraInfo`', () => { + const info = { + id: 'test', + name: 'test user', + extraInfo: { premium: true } + }; + + AccountInfoSingleton.getInstance().setAccountInfo(info); + AccountInfoSingleton.getInstance().addAccountExtraInfo({ + testGroup: 'A' + }); + + expect(AccountInfoSingleton.getInstance().getAccountInfo()).toEqual({ + ...info, + extraInfo: { ...info.extraInfo, testGroup: 'A' } + }); + }); + + it('clears account info with `clearAccountInfo`', () => { + AccountInfoSingleton.getInstance().setAccountInfo({ + id: 'test', + name: 'test user', + extraInfo: { premium: true } + }); + + AccountInfoSingleton.getInstance().clearAccountInfo(); + + expect( + AccountInfoSingleton.getInstance().getAccountInfo() + ).toBeUndefined(); + }); + + it('`reset()` replaces the provider and clears stored account info', () => { + const instanceBefore = AccountInfoSingleton.getInstance(); + + AccountInfoSingleton.getInstance().setAccountInfo({ + id: 'test', + name: 'test user', + extraInfo: { premium: true } + }); + + AccountInfoSingleton.reset(); + + const instanceAfter = AccountInfoSingleton.getInstance(); + + expect(instanceAfter).not.toBe(instanceBefore); + + expect(instanceAfter.getAccountInfo()).toBeUndefined(); + }); + + it('getInstance returns the same provider between calls (singleton behavior)', () => { + const a = AccountInfoSingleton.getInstance(); + const b = AccountInfoSingleton.getInstance(); + + expect(a).toBe(b); + }); +}); diff --git a/packages/core/src/sdk/AccountInfoSingleton/types.ts b/packages/core/src/sdk/AccountInfoSingleton/types.ts new file mode 100644 index 000000000..1dceb0958 --- /dev/null +++ b/packages/core/src/sdk/AccountInfoSingleton/types.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export type AccountInfo = { + readonly id: string; + readonly name?: string; + extraInfo?: Record; +}; diff --git a/packages/core/src/sdk/DatadogInternalBridge/DdSdkInternalNativeBridge.tsx b/packages/core/src/sdk/DatadogInternalBridge/DdSdkInternalNativeBridge.tsx index bbae2cd12..34a1e623a 100644 --- a/packages/core/src/sdk/DatadogInternalBridge/DdSdkInternalNativeBridge.tsx +++ b/packages/core/src/sdk/DatadogInternalBridge/DdSdkInternalNativeBridge.tsx @@ -5,7 +5,7 @@ */ import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; -import { setCachedSessionId } from '../../rum/sessionId/sessionIdHelper'; +import { setCachedSessionId } from '../../rum/helper'; import { DatadogDefaultEventEmitter } from '../DatadogEventEmitter/DatadogDefaultEventEmitter'; import type { DatadogEventEmitter } from '../DatadogEventEmitter/DatadogEventEmitter'; diff --git a/packages/core/src/sdk/EventMappers/EventMapper.ts b/packages/core/src/sdk/EventMappers/EventMapper.ts index 9ca252d72..e1cbaae19 100644 --- a/packages/core/src/sdk/EventMappers/EventMapper.ts +++ b/packages/core/src/sdk/EventMappers/EventMapper.ts @@ -7,6 +7,8 @@ import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; import { DdSdk } from '../../sdk/DdSdk'; +import { AccountInfoSingleton } from '../AccountInfoSingleton/AccountInfoSingleton'; +import type { AccountInfo } from '../AccountInfoSingleton/types'; import { AttributesSingleton } from '../AttributesSingleton/AttributesSingleton'; import type { Attributes } from '../AttributesSingleton/types'; import { UserInfoSingleton } from '../UserInfoSingleton/UserInfoSingleton'; @@ -16,6 +18,7 @@ import { deepClone } from './utils/deepClone'; export type AdditionalEventDataForMapper = { userInfo?: UserInfo; + accountInfo?: AccountInfo; attributes: Attributes; }; @@ -66,9 +69,11 @@ export class EventMapper { // formatting const userInfo = UserInfoSingleton.getInstance().getUserInfo(); + const accountInfo = AccountInfoSingleton.getInstance().getAccountInfo(); const attributes = AttributesSingleton.getInstance().getAttributes(); const initialEvent = this.formatRawEventForMapper(rawEvent, { userInfo, + accountInfo, attributes }); diff --git a/packages/core/src/sdk/UserInfoSingleton/UserInfoSingleton.ts b/packages/core/src/sdk/UserInfoSingleton/UserInfoSingleton.ts index 3ce23614b..2408fdbf2 100644 --- a/packages/core/src/sdk/UserInfoSingleton/UserInfoSingleton.ts +++ b/packages/core/src/sdk/UserInfoSingleton/UserInfoSingleton.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import { setCachedUserId } from '../../rum/helper'; + import type { UserInfo } from './types'; class UserInfoProvider { @@ -11,6 +13,7 @@ class UserInfoProvider { setUserInfo = (userInfo: UserInfo) => { this.userInfo = userInfo; + setCachedUserId(this.userInfo.id); }; getUserInfo = (): UserInfo | undefined => { diff --git a/packages/core/src/specs/NativeDdSdk.ts b/packages/core/src/specs/NativeDdSdk.ts index 70401fe3c..68c9c4711 100644 --- a/packages/core/src/specs/NativeDdSdk.ts +++ b/packages/core/src/specs/NativeDdSdk.ts @@ -67,6 +67,23 @@ export interface Spec extends TurboModule { */ addUserExtraInfo(extraInfo: Object): Promise; + /** + * Set the account information. + * @param account: The account object (use builtin attributes: 'id', 'name', and any custom attribute under extraInfo). + */ + setAccountInfo(account: Object): Promise; + + /** + * Clears the account information. + */ + clearAccountInfo(): Promise; + + /** + * Add custom attributes to the current account information + * @param extraInfo: The extraInfo object containing additional custom attributes + */ + addAccountExtraInfo(extraInfo: Object): Promise; + /** * Set the tracking consent regarding the data collection. * @param trackingConsent: Consent, which can take one of the following values: 'pending', 'granted', 'not_granted'. diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index 5c7d64cec..7f97779ee 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -129,6 +129,27 @@ export type DdSdkType = { */ addUserExtraInfo(extraUserInfo: Record): Promise; + /** + * Sets the account information. + * @param id: A unique account identifier (relevant to your business domain) + * @param name: The account name. + * @param extraInfo: Additional information. + */ + setAccountInfo(accountInfo: AccountInfo): Promise; + + /** + * Clears the account information. + */ + clearAccountInfo(): Promise; + + /** + * Add additional account information. + * @param extraAccountInfo: The additional information. (To set the id or name please use setAccountInfo). + */ + addAccountExtraInfo( + extraAccountInfo: Record + ): Promise; + /** * Set the tracking consent regarding the data collection. * @param trackingConsent: Consent, which can take one of the following values: 'pending', 'granted', 'not_granted'. @@ -176,6 +197,12 @@ export type UserInfo = { extraInfo?: object; }; +export type AccountInfo = { + id: string; + name?: string; + extraInfo?: object; +}; + // DdLogs export type LogStatus = 'debug' | 'info' | 'warn' | 'error';