@rudderstack/analytics-js 3.25.1 → 3.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/npm/legacy/bundled/cjs/index.cjs +49 -30
- package/dist/npm/legacy/bundled/esm/index.mjs +49 -30
- package/dist/npm/legacy/bundled/umd/index.js +49 -30
- package/dist/npm/legacy/cjs/index.cjs +49 -30
- package/dist/npm/legacy/content-script/cjs/index.cjs +49 -30
- package/dist/npm/legacy/content-script/esm/index.mjs +49 -30
- package/dist/npm/legacy/content-script/umd/index.js +49 -30
- package/dist/npm/legacy/esm/index.mjs +49 -30
- package/dist/npm/legacy/umd/index.js +49 -30
- package/dist/npm/modern/bundled/cjs/index.cjs +48 -29
- package/dist/npm/modern/bundled/esm/index.mjs +48 -29
- package/dist/npm/modern/bundled/umd/index.js +48 -29
- package/dist/npm/modern/cjs/index.cjs +48 -29
- package/dist/npm/modern/content-script/cjs/index.cjs +48 -29
- package/dist/npm/modern/content-script/esm/index.mjs +48 -29
- package/dist/npm/modern/content-script/umd/index.js +48 -29
- package/dist/npm/modern/esm/index.mjs +48 -29
- package/dist/npm/modern/umd/index.js +48 -29
- package/package.json +1 -1
|
@@ -512,7 +512,7 @@
|
|
|
512
512
|
error.stacktrace=`${stacktrace}\n${MANUAL_ERROR_IDENTIFIER}`;break;case operaSourceloc:default:// eslint-disable-next-line no-param-reassign
|
|
513
513
|
error['opera#sourceloc']=`${operaSourceloc}\n${MANUAL_ERROR_IDENTIFIER}`;break;}}}globalThis.dispatchEvent(new ErrorEvent('error',{error,bubbles:true,cancelable:true,composed:true}));};
|
|
514
514
|
|
|
515
|
-
const APP_NAME='RudderLabs JavaScript SDK';const APP_VERSION='3.
|
|
515
|
+
const APP_NAME='RudderLabs JavaScript SDK';const APP_VERSION='3.27.0';const APP_NAMESPACE='com.rudderlabs.javascript';const MODULE_TYPE='npm';const ADBLOCK_PAGE_CATEGORY='RudderJS-Initiated';const ADBLOCK_PAGE_NAME='ad-block page request';const ADBLOCK_PAGE_PATH='/ad-blocked';const GLOBAL_PRELOAD_BUFFER='preloadedEventsBuffer';const CONSENT_TRACK_EVENT_NAME='Consent Management Interaction';
|
|
516
516
|
|
|
517
517
|
const QUERY_PARAM_TRAIT_PREFIX='ajs_trait_';const QUERY_PARAM_PROPERTY_PREFIX='ajs_prop_';const QUERY_PARAM_ANONYMOUS_ID_KEY='ajs_aid';const QUERY_PARAM_USER_ID_KEY='ajs_uid';const QUERY_PARAM_TRACK_EVENT_NAME_KEY='ajs_event';
|
|
518
518
|
|
|
@@ -540,8 +540,9 @@
|
|
|
540
540
|
* Parse query string into preload buffer events & push into existing array before any other events
|
|
541
541
|
*/const retrieveEventsFromQueryString=(argumentsArray=[])=>{// Mapping for trait and properties values based on key prefix
|
|
542
542
|
const eventArgumentToQueryParamMap={trait:QUERY_PARAM_TRAIT_PREFIX,properties:QUERY_PARAM_PROPERTY_PREFIX};const queryObject=new URLSearchParams(globalThis.location.search);// Add track events with name and properties
|
|
543
|
-
if(queryObject.get(QUERY_PARAM_TRACK_EVENT_NAME_KEY)){argumentsArray.unshift(['track',queryObject.get(QUERY_PARAM_TRACK_EVENT_NAME_KEY),getEventDataFromQueryString(queryObject,eventArgumentToQueryParamMap.properties)]);}//
|
|
544
|
-
|
|
543
|
+
if(queryObject.get(QUERY_PARAM_TRACK_EVENT_NAME_KEY)){argumentsArray.unshift(['track',queryObject.get(QUERY_PARAM_TRACK_EVENT_NAME_KEY),getEventDataFromQueryString(queryObject,eventArgumentToQueryParamMap.properties)]);}// Send identify event
|
|
544
|
+
const userId=queryObject.get(QUERY_PARAM_USER_ID_KEY);const userTraits=getEventDataFromQueryString(queryObject,eventArgumentToQueryParamMap.trait);if(userId||isNonEmptyObject(userTraits)){// In identify API, user ID is optional
|
|
545
|
+
const identifyApiArgs=[...(userId?[userId]:[]),userTraits];argumentsArray.unshift(['identify',...identifyApiArgs]);}// Set anonymousID
|
|
545
546
|
if(queryObject.get(QUERY_PARAM_ANONYMOUS_ID_KEY)){argumentsArray.unshift(['setAnonymousId',queryObject.get(QUERY_PARAM_ANONYMOUS_ID_KEY)]);}};/**
|
|
546
547
|
* Retrieve an existing buffered load method call and remove from the existing array
|
|
547
548
|
*/const getPreloadedLoadEvent=preloadedEventsArray=>{const loadMethodName='load';let loadEvent=[];/**
|
|
@@ -823,16 +824,14 @@
|
|
|
823
824
|
// Ex: parentFolderName will be 'sample' for url: https://example.com/sample/file.min.js
|
|
824
825
|
const parentFolderName=paths[paths.length-2];return parentFolderName===CDN_INT_DIR||SDK_FILE_NAME_PREFIXES().some(prefix=>srcFileName.startsWith(prefix)&&srcFileName.endsWith('.js'));};const getErrorDeliveryPayload=(payload,state,category)=>{const data={version:METRICS_PAYLOAD_VERSION,message_id:generateUUID(),source:{name:SOURCE_NAME,sdk_version:state.context.app.value.version,write_key:state.lifecycle.writeKey.value,install_type:state.context.app.value.installType,category:category??DEFAULT_ERROR_CATEGORY},errors:payload};return stringifyWithoutCircular(data);};/**
|
|
825
826
|
* A function to get the grouping hash value to be used for the error event.
|
|
826
|
-
* Grouping hash is suppressed for non-cdn installs.
|
|
827
827
|
* If the grouping hash is an error instance, the normalized error message is used as the grouping hash.
|
|
828
828
|
* If the grouping hash is an empty string or not specified, the default grouping hash is used.
|
|
829
829
|
* If the grouping hash is a string, it is used as is.
|
|
830
830
|
* @param curErrGroupingHash The grouping hash value part of the error event
|
|
831
831
|
* @param defaultGroupingHash The default grouping hash value. It is the error message.
|
|
832
|
-
* @param state The application state
|
|
833
832
|
* @param logger The logger instance
|
|
834
833
|
* @returns The final grouping hash value to be used for the error event
|
|
835
|
-
*/const getErrorGroupingHash=(curErrGroupingHash,defaultGroupingHash,
|
|
834
|
+
*/const getErrorGroupingHash=(curErrGroupingHash,defaultGroupingHash,logger)=>{let normalizedGroupingHash;if(!isDefined(curErrGroupingHash)){normalizedGroupingHash=defaultGroupingHash;}else if(isString(curErrGroupingHash)){normalizedGroupingHash=curErrGroupingHash;}else {const normalizedErrorInstance=normalizeError(curErrGroupingHash,logger);if(isDefined(normalizedErrorInstance)){normalizedGroupingHash=normalizedErrorInstance.message;}else {normalizedGroupingHash=defaultGroupingHash;}}return normalizedGroupingHash;};
|
|
836
835
|
|
|
837
836
|
/**
|
|
838
837
|
* A service to handle errors
|
|
@@ -856,13 +855,13 @@
|
|
|
856
855
|
*/async onError(errorInfo){try{const{error,context,customMessage,groupingHash,category}=errorInfo;const errorType=errorInfo.errorType??ErrorType.HANDLEDEXCEPTION;const errInstance=getErrInstance(error,errorType);const normalizedError=normalizeError(errInstance,this.logger);if(isUndefined(normalizedError)){return;}const customMsgVal=customMessage?`${customMessage} - `:'';const errorMsgPrefix=`${context}${LOG_CONTEXT_SEPARATOR}${customMsgVal}`;const bsException=createBugsnagException(normalizedError,errorMsgPrefix);const stacktrace=getStacktrace(normalizedError);const isSdkDispatched=stacktrace.includes(MANUAL_ERROR_IDENTIFIER);// Filter errors that are not originated in the SDK.
|
|
857
856
|
// In case of NPM installations, the unhandled errors from the SDK cannot be identified
|
|
858
857
|
// and will NOT be reported unless they occur in plugins or integrations.
|
|
859
|
-
if(!isSdkDispatched&&!isSDKError(bsException)&&errorType!==ErrorType.HANDLEDEXCEPTION){return;}if(state.reporting.isErrorReportingEnabled.value){const isAllowed=await checkIfAllowedToBeNotified(bsException,state,this.httpClient);if(isAllowed){const errorState={severity:'error',unhandled:errorType!==ErrorType.HANDLEDEXCEPTION,severityReason:{type:errorType}};//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
858
|
+
if(!isSdkDispatched&&!isSDKError(bsException)&&errorType!==ErrorType.HANDLEDEXCEPTION){return;}if(state.reporting.isErrorReportingEnabled.value){const isAllowed=await checkIfAllowedToBeNotified(bsException,state,this.httpClient);if(isAllowed){const errorState={severity:'error',unhandled:errorType!==ErrorType.HANDLEDEXCEPTION,severityReason:{type:errorType}};// This will allow custom grouping of errors.
|
|
859
|
+
// In case of NPM installations, the default grouping by surrounding code
|
|
860
|
+
// does not make sense as each user application is different and will create a lot of noise in the alerts.
|
|
862
861
|
// References:
|
|
863
862
|
// https://docs.bugsnag.com/platforms/javascript/customizing-error-reports/#groupinghash
|
|
864
863
|
// https://docs.bugsnag.com/product/error-grouping/#user_defined
|
|
865
|
-
const normalizedGroupingHash=getErrorGroupingHash(groupingHash,bsException.message,
|
|
864
|
+
const normalizedGroupingHash=getErrorGroupingHash(groupingHash,bsException.message,this.logger);// Get the final payload to be sent to the metrics service
|
|
866
865
|
const bugsnagPayload=getBugsnagErrorEvent(bsException,errorState,state,normalizedGroupingHash);// send it to metrics service
|
|
867
866
|
this.httpClient.getAsyncData({url:state.metrics.metricsServiceUrl.value,options:{method:'POST',data:getErrorDeliveryPayload(bugsnagPayload,state,category),sendRawData:true},isRawResponse:true});}}// Log handled errors and errors dispatched by the SDK
|
|
868
867
|
if(errorType===ErrorType.HANDLEDEXCEPTION||isSdkDispatched){this.logger.error(bsException.message);}}catch(err){// If an error occurs while handling an error, log it
|
|
@@ -1587,7 +1586,9 @@
|
|
|
1587
1586
|
* @param event Incoming event data
|
|
1588
1587
|
*/addEvent(event){this.userSessionManager.refreshSession();const rudderEvent=this.eventFactory.create(event);this.eventRepository.enqueue(rudderEvent,event.callback);}}
|
|
1589
1588
|
|
|
1590
|
-
class UserSessionManager{
|
|
1589
|
+
class UserSessionManager{/**
|
|
1590
|
+
* Tracks whether a server-side cookie setting request is in progress or not.
|
|
1591
|
+
*/constructor(pluginsManager,storeManager,httpClient,errorHandler,logger){this.storeManager=storeManager;this.pluginsManager=pluginsManager;this.logger=logger;this.errorHandler=errorHandler;this.httpClient=httpClient;this.onError=this.onError.bind(this);this.serverSideCookieDebounceFuncs={};this.serverSideCookiesRequestInProgress={};}/**
|
|
1591
1592
|
* Initialize User session with values from storage
|
|
1592
1593
|
*/init(){this.syncStorageDataToState();// Register the effect to sync with storage
|
|
1593
1594
|
this.registerEffects();}syncStorageDataToState(){this.migrateStorageIfNeeded();this.migrateDataFromPreviousStorage();// get the values from storage and set it again
|
|
@@ -1617,20 +1618,31 @@
|
|
|
1617
1618
|
* @param callback
|
|
1618
1619
|
*/makeRequestToSetCookie(encryptedCookieData,callback){this.httpClient?.getAsyncData({url:state.serverCookies.dataServiceUrl.value,options:{method:'POST',data:stringifyWithoutCircular({reqType:'setCookies',workspaceId:state.source.value?.workspaceId,data:{options:{maxAge:state.storage.cookie.value?.maxage,path:state.storage.cookie.value?.path,domain:state.storage.cookie.value?.domain,sameSite:state.storage.cookie.value?.samesite,secure:state.storage.cookie.value?.secure,expires:state.storage.cookie.value?.expires},cookies:encryptedCookieData}}),sendRawData:true,withCredentials:true},isRawResponse:true,callback});}/**
|
|
1619
1620
|
* A function to make an external request to set the cookie from server side
|
|
1620
|
-
* @param key
|
|
1621
|
-
* @param
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1621
|
+
* @param sessionToCookiesMap map of session key to cookie name
|
|
1622
|
+
* @param cb callback function to be called when the cookie is set
|
|
1623
|
+
* @param store store to be used to get the cookie value
|
|
1624
|
+
*/setServerSideCookies(sessionToCookiesMap,cb,store){// Retrieve the cookie value from the state
|
|
1625
|
+
const sessionKeys=Object.keys(sessionToCookiesMap);const getCurrentCookieValuesFromState=()=>{return sessionKeys.map(sessionKey=>{return {name:sessionToCookiesMap[sessionKey].name,value:state.session[sessionKey].value};});};// Preserve the current cookie values
|
|
1626
|
+
const originalCookieValues={};sessionKeys.forEach(sessionKey=>{originalCookieValues[sessionToCookiesMap[sessionKey].name]=store?.get(sessionToCookiesMap[sessionKey].name);});const clearInProgressFlags=()=>{sessionKeys.forEach(sessionKey=>{this.serverSideCookiesRequestInProgress[sessionKey]=false;});};const setCookiesClientSide=()=>{getCurrentCookieValuesFromState().forEach(each=>{if(cb){cb(each.name,each.value);}});};try{const expectedCookieValues={};sessionKeys.forEach(sessionKey=>{expectedCookieValues[sessionToCookiesMap[sessionKey].name]=state.session[sessionKey].value;});// encrypt cookies values
|
|
1627
|
+
const encryptedCookieData=this.getEncryptedCookieData(getCurrentCookieValuesFromState(),store);if(encryptedCookieData.length>0){// make request to data service to set the cookie from server side
|
|
1628
|
+
this.makeRequestToSetCookie(encryptedCookieData,(res,details)=>{// Mark the cookie req status as done
|
|
1629
|
+
clearInProgressFlags();if(details?.xhr?.status===200){getCurrentCookieValuesFromState().forEach(cData=>{const originalCookieVal=originalCookieValues[cData.name];const currentCookieVal=store?.get(cData.name);// Check if the expected cookie values are set.
|
|
1630
|
+
if(stringifyWithoutCircular(expectedCookieValues[cData.name],false,[])!==stringifyWithoutCircular(currentCookieVal,false,[])){// It's fine if the values don't match as other active SDK sessions might have updated the cookie values
|
|
1631
|
+
// or other cookie requests might have updated the cookie value.
|
|
1632
|
+
// Log an error only when cookie didn't exist previously and currently also doesn't exist.
|
|
1633
|
+
if(isNull(originalCookieVal)&&isNull(currentCookieVal)){this.logger.error(FAILED_SETTING_COOKIE_FROM_SERVER_ERROR(cData.name));}if(cb){cb(cData.name,cData.value);}}});}else {this.logger.error(DATA_SERVER_REQUEST_FAIL_ERROR(details?.xhr?.status));setCookiesClientSide();}});}else {setCookiesClientSide();// Mark the cookie req status as done
|
|
1634
|
+
clearInProgressFlags();}}catch(e){this.onError(e,FAILED_SETTING_COOKIE_FROM_SERVER_GLOBAL_ERROR,FAILED_SETTING_COOKIE_FROM_SERVER_GLOBAL_ERROR);setCookiesClientSide();// Mark the cookie req status as done
|
|
1635
|
+
clearInProgressFlags();}}/**
|
|
1625
1636
|
* A function to sync values in storage
|
|
1626
1637
|
* @param sessionKey
|
|
1627
|
-
|
|
1628
|
-
*/syncValueToStorage(sessionKey,value){const entries=state.storage.entries.value;const storageType=entries[sessionKey]?.type;if(isStorageTypeValidForStoringData(storageType)){const curStore=this.storeManager?.getStore(storageClientDataStoreNameMap[storageType]);const key=entries[sessionKey]?.key;if(value&&(isString(value)||isNonEmptyObject(value))){// if useServerSideCookies load option is set to true
|
|
1638
|
+
*/syncValueToStorage(sessionKey){const entries=state.storage.entries.value;const storageType=entries[sessionKey]?.type;if(isStorageTypeValidForStoringData(storageType)){const curStore=this.storeManager.getStore(storageClientDataStoreNameMap[storageType]);const cookieName=entries[sessionKey]?.key;const cookieValue=state.session[sessionKey].value;if(cookieValue&&(isString(cookieValue)||isNonEmptyObject(cookieValue))){// if useServerSideCookies load option is set to true
|
|
1629
1639
|
// set the cookie from server side
|
|
1630
|
-
if(state.serverCookies.isEnabledServerSideCookies.value&&storageType===COOKIE_STORAGE){
|
|
1640
|
+
if(state.serverCookies.isEnabledServerSideCookies.value&&storageType===COOKIE_STORAGE){// Mark the requests as in progress.
|
|
1641
|
+
this.serverSideCookiesRequestInProgress[sessionKey]=true;if(this.serverSideCookieDebounceFuncs[sessionKey]){globalThis.clearTimeout(this.serverSideCookieDebounceFuncs[sessionKey]);}this.serverSideCookieDebounceFuncs[sessionKey]=globalThis.setTimeout(()=>{// Create a map of session key to cookie name
|
|
1642
|
+
const sessionToCookiesMap={[sessionKey]:{name:cookieName}};this.setServerSideCookies(sessionToCookiesMap,(cookieName,cookieValue)=>{curStore?.set(cookieName,cookieValue);},curStore);},SERVER_SIDE_COOKIES_DEBOUNCE_TIME);}else {curStore?.set(cookieName,cookieValue);}}else {curStore?.remove(cookieName);}}}/**
|
|
1631
1643
|
* Function to update storage whenever state value changes
|
|
1632
1644
|
*/registerEffects(){// This will work as long as the user session entry key names are same as the state keys
|
|
1633
|
-
USER_SESSION_KEYS.forEach(sessionKey=>{E(()=>{this.syncValueToStorage(sessionKey
|
|
1645
|
+
USER_SESSION_KEYS.forEach(sessionKey=>{E(()=>{this.syncValueToStorage(sessionKey);});});}/**
|
|
1634
1646
|
* Sets anonymous id in the following precedence:
|
|
1635
1647
|
*
|
|
1636
1648
|
* 1. anonymousId: Id directly provided to the function.
|
|
@@ -1647,30 +1659,37 @@
|
|
|
1647
1659
|
// This is needed for entries that are fetched from the storage
|
|
1648
1660
|
// during the current session (for example, session info)
|
|
1649
1661
|
this.migrateStorageIfNeeded([store],[sessionKey]);const storageKey=entries[sessionKey]?.key;return store?.get(storageKey)??null;}return null;}getExternalAnonymousIdByCookieName(key){const storageEngine=getStorageEngine(COOKIE_STORAGE);if(storageEngine?.isEnabled){return storageEngine.getItem(key)??null;}return null;}/**
|
|
1662
|
+
* Fetches the value for a session key. Preferably from storage, if the server-side
|
|
1663
|
+
* cookies request is not in progress. Otherwise, from the state.
|
|
1664
|
+
* @param sessionKey - The session key to fetch the value for
|
|
1665
|
+
* @returns - The value for the session key
|
|
1666
|
+
*/getUserSessionValue(sessionKey){// If the server-side cookies request is in progress, fetch the value from the state.
|
|
1667
|
+
if(this.serverSideCookiesRequestInProgress[sessionKey]){return state.session[sessionKey].value;}// Otherwise, fetch the value from storage.
|
|
1668
|
+
return this.getEntryValue(sessionKey);}/**
|
|
1650
1669
|
* Fetches User Id
|
|
1651
1670
|
* @returns
|
|
1652
|
-
*/getUserId(){return this.
|
|
1671
|
+
*/getUserId(){return this.getUserSessionValue('userId');}/**
|
|
1653
1672
|
* Fetches User Traits
|
|
1654
1673
|
* @returns
|
|
1655
|
-
*/getUserTraits(){return this.
|
|
1674
|
+
*/getUserTraits(){return this.getUserSessionValue('userTraits');}/**
|
|
1656
1675
|
* Fetches Group Id
|
|
1657
1676
|
* @returns
|
|
1658
|
-
*/getGroupId(){return this.
|
|
1677
|
+
*/getGroupId(){return this.getUserSessionValue('groupId');}/**
|
|
1659
1678
|
* Fetches Group Traits
|
|
1660
1679
|
* @returns
|
|
1661
|
-
*/getGroupTraits(){return this.
|
|
1680
|
+
*/getGroupTraits(){return this.getUserSessionValue('groupTraits');}/**
|
|
1662
1681
|
* Fetches Initial Referrer
|
|
1663
1682
|
* @returns
|
|
1664
|
-
*/getInitialReferrer(){return this.
|
|
1683
|
+
*/getInitialReferrer(){return this.getUserSessionValue('initialReferrer');}/**
|
|
1665
1684
|
* Fetches Initial Referring domain
|
|
1666
1685
|
* @returns
|
|
1667
|
-
*/getInitialReferringDomain(){return this.
|
|
1686
|
+
*/getInitialReferringDomain(){return this.getUserSessionValue('initialReferringDomain');}/**
|
|
1668
1687
|
* Fetches session tracking information from storage
|
|
1669
1688
|
* @returns
|
|
1670
|
-
*/getSessionInfo(){return this.
|
|
1689
|
+
*/getSessionInfo(){return this.getUserSessionValue('sessionInfo');}/**
|
|
1671
1690
|
* Fetches auth token from storage
|
|
1672
1691
|
* @returns
|
|
1673
|
-
*/getAuthToken(){return this.
|
|
1692
|
+
*/getAuthToken(){return this.getUserSessionValue('authToken');}/**
|
|
1674
1693
|
* If session is active it returns the sessionId
|
|
1675
1694
|
* @returns
|
|
1676
1695
|
*/getSessionId(){const sessionInfo=this.getSessionInfo()??DEFAULT_USER_SESSION_VALUES.sessionInfo;if(sessionInfo.autoTrack&&!hasSessionExpired(sessionInfo)||sessionInfo.manualTrack){return sessionInfo.id??null;}return null;}/**
|
|
@@ -1686,7 +1705,7 @@
|
|
|
1686
1705
|
if(sessionInfo.sessionStart===undefined){sessionInfo={...sessionInfo,sessionStart:true};}else if(sessionInfo.sessionStart){sessionInfo={...sessionInfo,sessionStart:false};}}// Always write to state (in-turn to storage) to keep the session info up to date.
|
|
1687
1706
|
state.session.sessionInfo.value=sessionInfo;if(state.lifecycle.status.value!=='readyExecuted'){// Force update the storage as the 'effect' blocks are not getting triggered
|
|
1688
1707
|
// when processing preload buffered requests
|
|
1689
|
-
this.syncValueToStorage('sessionInfo'
|
|
1708
|
+
this.syncValueToStorage('sessionInfo');}}resetAndStartNewSession(){const session=state.session;const{manualTrack,autoTrack,timeout,cutOff}=session.sessionInfo.value;if(autoTrack){const sessionInfo={...DEFAULT_USER_SESSION_VALUES.sessionInfo,timeout};if(cutOff){sessionInfo.cutOff={enabled:cutOff.enabled,duration:cutOff.duration};}session.sessionInfo.value=sessionInfo;this.startOrRenewAutoTracking(session.sessionInfo.value);}else if(manualTrack){this.startManualTrackingInternal();}}/**
|
|
1690
1709
|
* Reset state values
|
|
1691
1710
|
* @param options options for reset
|
|
1692
1711
|
* @returns
|