From 2c32d66b5e998cbe850d460eb7f1d399daab53db Mon Sep 17 00:00:00 2001 From: JuanSe Cardenas Rodriguez Date: Mon, 2 Sep 2024 17:00:50 -0500 Subject: [PATCH] Create handler for streaming performance metrics. This new handler will allow any application developer to register a function for analyzing or storing performance metrics related to latencies due to a message delay sending the response back from the host to the app. --- .../teams-test-app/src/components/AppAPIs.tsx | 11 ++ .../teams-js/src/internal/communication.ts | 24 ++- packages/teams-js/src/internal/handlers.ts | 24 ++- packages/teams-js/src/internal/interfaces.ts | 12 ++ .../teams-js/src/internal/messageObjects.ts | 1 + packages/teams-js/src/internal/utils.ts | 4 +- packages/teams-js/src/public/app.ts | 26 +++- packages/teams-js/src/public/index.ts | 1 + packages/teams-js/src/public/interfaces.ts | 14 +- .../test/internal/communication.spec.ts | 3 +- packages/teams-js/test/public/app.spec.ts | 137 ++++++++++++++++++ packages/teams-js/test/utils.ts | 12 +- 12 files changed, 259 insertions(+), 10 deletions(-) diff --git a/apps/teams-test-app/src/components/AppAPIs.tsx b/apps/teams-test-app/src/components/AppAPIs.tsx index 06f9e1cc5f..afe3976435 100644 --- a/apps/teams-test-app/src/components/AppAPIs.tsx +++ b/apps/teams-test-app/src/components/AppAPIs.tsx @@ -64,6 +64,16 @@ const OpenLink = (): ReactElement => defaultInput: '"https://teams.microsoft.com/l/call/0/0?users=testUser1,testUser2&withVideo=true&source=test"', }); +const RegisterHubToAppPerformanceMetricsHandler = (): ReactElement => + ApiWithoutInput({ + name: 'registerHubToAppPerformanceMetricsHandler', + title: 'Register Hub to App performance metrics handler', + onClick: async (setResult) => { + app.registerHubToAppPerformanceMetricsHandler((v) => setResult(JSON.stringify(v))); + return ''; + }, + }); + const RegisterOnThemeChangeHandler = (): ReactElement => ApiWithoutInput({ name: 'registerOnThemeChangeHandler', @@ -128,6 +138,7 @@ const AppAPIs = (): ReactElement => ( + diff --git a/packages/teams-js/src/internal/communication.ts b/packages/teams-js/src/internal/communication.ts index 097bbb8acd..6aaaa209ed 100644 --- a/packages/teams-js/src/internal/communication.ts +++ b/packages/teams-js/src/internal/communication.ts @@ -8,8 +8,8 @@ import { SdkError } from '../public/interfaces'; import { latestRuntimeApiVersion } from '../public/runtime'; import { version } from '../public/version'; import { GlobalVars } from './globalVars'; -import { callHandler } from './handlers'; -import { DOMMessageEvent, ExtendedWindow } from './interfaces'; +import { callHandler, handleHostToAppPerformanceMetrics } from './handlers'; +import { CallbackInformation, DOMMessageEvent, ExtendedWindow } from './interfaces'; import { deserializeMessageRequest, deserializeMessageResponse, @@ -59,6 +59,7 @@ class CommunicationPrivate { public static topMessageQueue: MessageRequest[] = []; public static nextMessageId = 0; public static callbacks: Map = new Map(); + public static callbackInformation: Map = new Map(); public static promiseCallbacks: Map void> = new Map(); public static portCallbacks: Map void> = new Map(); public static messageListener: Function; @@ -158,6 +159,7 @@ export function uninitializeCommunication(): void { CommunicationPrivate.promiseCallbacks.clear(); CommunicationPrivate.portCallbacks.clear(); CommunicationPrivate.legacyMessageIdsToUuidMap = {}; + CommunicationPrivate.callbackInformation.clear(); } /** @@ -420,9 +422,12 @@ function sendMessageToParentHelper( args: any[] | undefined, ): MessageRequestWithRequiredProperties { const logger = sendMessageToParentHelperLogger; - const targetWindow = Communication.parentWindow; const request = createMessageRequest(apiVersionTag, actionName, args); + CommunicationPrivate.callbackInformation.set(request.uuid, { + name: actionName, + calledAt: request.timestamp, + }); logger('Message %i information: %o', request.uuid, { actionName, args }); @@ -706,6 +711,19 @@ function handleParentMessage(evt: DOMMessageEvent): void { if (callbackId) { const callback = CommunicationPrivate.callbacks.get(callbackId); logger('Received a response from parent for message %i', callbackId); + + // Send performance metrics information of message delay + const callbackInformation = CommunicationPrivate.callbackInformation.get(callbackId); + if (callbackInformation && message.timestamp) { + handleHostToAppPerformanceMetrics({ + actionName: callbackInformation.name, + messageDelay: getCurrentTimestamp() - message.timestamp, + messageWasCreatedAt: callbackInformation.calledAt, + }); + } else { + logger('Unable to send performance metrics for callback %i with arguments %o', callbackId, message.args); + } + if (callback) { logger('Invoking the registered callback for message %i with arguments %o', callbackId, message.args); // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/teams-js/src/internal/handlers.ts b/packages/teams-js/src/internal/handlers.ts index e8e1d8935b..70565ecd76 100644 --- a/packages/teams-js/src/internal/handlers.ts +++ b/packages/teams-js/src/internal/handlers.ts @@ -2,7 +2,7 @@ import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry'; import { FrameContexts } from '../public/constants'; -import { LoadContext, ResumeContext } from '../public/interfaces'; +import { HostToAppPerformanceMetrics, LoadContext, ResumeContext } from '../public/interfaces'; import { pages } from '../public/pages'; import { runtime } from '../public/runtime'; import { Communication, sendMessageEventToChild, sendMessageToParent } from './communication'; @@ -31,6 +31,7 @@ class HandlersPrivate { public static beforeUnloadHandler: null | ((readyToUnload: () => void) => boolean) = null; public static beforeSuspendOrTerminateHandler: null | (() => Promise) = null; public static resumeHandler: null | ((context: ResumeContext) => void) = null; + public static hostToAppPerformanceMetricsHandler: null | ((metrics: HostToAppPerformanceMetrics) => void) = null; /** * @internal @@ -182,6 +183,27 @@ export function handleThemeChange(theme: string): void { } } +/** + * @internal + * Limited to Microsoft-internal use + */ +export function registerHostToAppPerformanceMetricsHandler( + handler: (metrics: HostToAppPerformanceMetrics) => void, +): void { + HandlersPrivate.hostToAppPerformanceMetricsHandler = handler; +} + +/** + * @internal + * Limited to Microsoft-internal use + */ +export function handleHostToAppPerformanceMetrics(metrics: HostToAppPerformanceMetrics): void { + if (!HandlersPrivate.hostToAppPerformanceMetricsHandler) { + return; + } + HandlersPrivate.hostToAppPerformanceMetricsHandler(metrics); +} + /** * @internal * Limited to Microsoft-internal use diff --git a/packages/teams-js/src/internal/interfaces.ts b/packages/teams-js/src/internal/interfaces.ts index d6433da27a..1b95ceb4a2 100644 --- a/packages/teams-js/src/internal/interfaces.ts +++ b/packages/teams-js/src/internal/interfaces.ts @@ -50,3 +50,15 @@ export interface DOMMessageEvent { func: string; args?: any[]; } + +/** + * @hidden + * Meant for providing information related to certain callback context. + * + * @internal + * Limited to Microsoft-internal use + */ +export interface CallbackInformation { + name: string; + calledAt: number; +} diff --git a/packages/teams-js/src/internal/messageObjects.ts b/packages/teams-js/src/internal/messageObjects.ts index bef5a4107e..7150a3f4f5 100644 --- a/packages/teams-js/src/internal/messageObjects.ts +++ b/packages/teams-js/src/internal/messageObjects.ts @@ -46,6 +46,7 @@ export interface SerializedMessageResponse { uuidAsString?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any args?: any[]; + timestamp?: number; isPartialResponse?: boolean; // If the message is partial, then there will be more future responses for the given message ID. } diff --git a/packages/teams-js/src/internal/utils.ts b/packages/teams-js/src/internal/utils.ts index aea8f21878..77dfbbe0a1 100644 --- a/packages/teams-js/src/internal/utils.ts +++ b/packages/teams-js/src/internal/utils.ts @@ -532,7 +532,7 @@ export function validateUuid(id: string | undefined | null): void { /** * Cache if performance timers are available to avoid redoing this on each function call. */ -const supportsPerformanceTimers = 'performance' in window && 'now' in window.performance; +const supportsPerformanceTimers = !!performance && 'now' in performance; /** * @internal @@ -540,5 +540,5 @@ const supportsPerformanceTimers = 'performance' in window && 'now' in window.per * @returns current timestamp in milliseconds */ export function getCurrentTimestamp(): number { - return supportsPerformanceTimers ? window.performance.now() + window.performance.timeOrigin : new Date().getTime(); + return supportsPerformanceTimers ? performance.now() + performance.timeOrigin : new Date().getTime(); } diff --git a/packages/teams-js/src/public/app.ts b/packages/teams-js/src/public/app.ts index 8efd2fa1e9..1b12e66390 100644 --- a/packages/teams-js/src/public/app.ts +++ b/packages/teams-js/src/public/app.ts @@ -23,7 +23,14 @@ import { messageChannels } from '../private/messageChannels'; import { authentication } from './authentication'; import { ChannelType, FrameContexts, HostClientType, HostName, TeamType, UserTeamRole } from './constants'; import { dialog } from './dialog'; -import { ActionInfo, Context as LegacyContext, FileOpenPreference, LocaleInfo, ResumeContext } from './interfaces'; +import { + ActionInfo, + Context as LegacyContext, + FileOpenPreference, + HostToAppPerformanceMetrics, + LocaleInfo, + ResumeContext, +} from './interfaces'; import { menus } from './menus'; import { pages } from './pages'; import { @@ -723,6 +730,11 @@ export namespace app { */ export type themeHandler = (theme: string) => void; + /** + * This function is passed to registerHostToAppPerformanceMetricsHandler. It is called every time a response is received from the host with metrics for analyzing message delay. See {@link HostToAppPerformanceMetrics} to see which metrics are passed to the handler. + */ + export type HostToAppPerformanceMetricsHandler = (metrics: HostToAppPerformanceMetrics) => void; + /** * Checks whether the Teams client SDK has been initialized. * @returns whether the Teams client SDK has been initialized. @@ -886,6 +898,18 @@ export namespace app { ); } + /** + * Registers a function for handling data of host to app message delay. + * + * @remarks + * Only one handler can be registered at a time. A subsequent registration replaces an existing registration. + * + * @param handler - The handler to invoke when the metrics are available on each function response. + */ + export function registerHostToAppPerformanceMetricsHandler(handler: HostToAppPerformanceMetricsHandler): void { + Handlers.registerHostToAppPerformanceMetricsHandler(handler); + } + /** * This function opens deep links to other modules in the host such as chats or channels or * general-purpose links (to external websites). It should not be used for navigating to your diff --git a/packages/teams-js/src/public/index.ts b/packages/teams-js/src/public/index.ts index 49193f2cf3..b0193bf04c 100644 --- a/packages/teams-js/src/public/index.ts +++ b/packages/teams-js/src/public/index.ts @@ -25,6 +25,7 @@ export { FileOpenPreference, FrameContext, FrameInfo, + HostToAppPerformanceMetrics, LoadContext, LocaleInfo, M365ContentAction, diff --git a/packages/teams-js/src/public/interfaces.ts b/packages/teams-js/src/public/interfaces.ts index 70ba0427c4..da4f8c0bbc 100644 --- a/packages/teams-js/src/public/interfaces.ts +++ b/packages/teams-js/src/public/interfaces.ts @@ -1008,7 +1008,7 @@ export interface SdkError { errorCode: ErrorCode; /** Optional description for the error. This may contain useful information for web-app developers. - This string will not be localized and is not for end-user consumption. + This string will not be localized and is not for end-user consumption. App should not depend on the string content. The exact value may change. This is only for debugging purposes. */ message?: string; @@ -1272,3 +1272,15 @@ export interface ClipboardParams { /** Blob content in Base64 string format */ content: string; } + +/** + * Meant for passing data to the app related to host-to-app message performance metrics. + */ +export interface HostToAppPerformanceMetrics { + /** The name of the action the host is responding to. */ + actionName: string; + /** The delay the message took traveling from host to app */ + messageDelay: number; + /** The time the message was originally created at */ + messageWasCreatedAt: number; +} diff --git a/packages/teams-js/test/internal/communication.spec.ts b/packages/teams-js/test/internal/communication.spec.ts index 7ef223f19b..5fc4107ca3 100644 --- a/packages/teams-js/test/internal/communication.spec.ts +++ b/packages/teams-js/test/internal/communication.spec.ts @@ -11,6 +11,7 @@ import { Utils } from '../utils'; jest.mock('../../src/internal/handlers', () => ({ callHandler: jest.fn(), + handleHostToAppPerformanceMetrics: jest.fn(), })); const testApiVersion = getApiVersionTag(ApiVersionNumber.V_1, 'mockedApiName' as ApiName); @@ -275,7 +276,7 @@ describe('Testing communication', () => { it('should set Communication.parentWindow and Communication.parentOrigin to null if the parent window is closed during the initialization call', async () => { expect.assertions(4); - /* + /* This promise is intentionally not being awaited If the parent window is closed during the initialize call, the initialize response never resolves (even though we receive it) diff --git a/packages/teams-js/test/public/app.spec.ts b/packages/teams-js/test/public/app.spec.ts index 27fc99e6dc..1ef887b700 100644 --- a/packages/teams-js/test/public/app.spec.ts +++ b/packages/teams-js/test/public/app.spec.ts @@ -1,6 +1,7 @@ import { errorLibraryNotInitialized } from '../../src/internal/constants'; import { GlobalVars } from '../../src/internal/globalVars'; import { DOMMessageEvent } from '../../src/internal/interfaces'; +import { UUID } from '../../src/internal/uuidObject'; import { authentication, dialog, menus, pages } from '../../src/public'; import { app } from '../../src/public/app'; import { @@ -58,6 +59,7 @@ describe('Testing app capability', () => { utils.childMessages = []; utils.childWindow.closed = false; utils.mockWindow.parent = utils.parentWindow; + utils.setRespondWithTimestamp(false); // Set a mock window for testing app._initialize(utils.mockWindow); @@ -795,6 +797,73 @@ describe('Testing app capability', () => { }); }); + describe('Testing hostToAppPerformanceMetricsHandler', () => { + it('app.registerHostToAppPerformanceMetricsHandler registered function should get called when api SDK call has timestamp', async () => { + await utils.initializeWithContext('content'); + utils.setRespondWithTimestamp(true); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handler).toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler registered function should not get called when api SDK call does not have timestamp', async () => { + await utils.initializeWithContext('content'); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handler).not.toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler registered function should not get called when api SDK call response has no info', async () => { + await utils.initializeWithContext('content'); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + await utils.respondToMessage({ uuid: new UUID(), func: 'weirdFunc' }, {}); + expect(handler).not.toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler should replace previously registered handler', async () => { + await utils.initializeWithContext('content'); + utils.setRespondWithTimestamp(true); + + const handlerOne = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handlerOne); + const handlerTwo = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handlerTwo); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handlerTwo).toBeCalled(); + expect(handlerOne).not.toBeCalled(); + }); + }); + describe('Testing app.registerOnThemeChangeHandler function', () => { it('app.registerOnThemeChangeHandler should not allow calls before initialization', () => { // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -944,6 +1013,7 @@ describe('Testing app capability', () => { utils = new Utils(); utils.mockWindow.parent = undefined; utils.messages = []; + utils.setRespondWithTimestamp(false); app._initialize(utils.mockWindow); GlobalVars.isFramelessWindow = false; }); @@ -1590,6 +1660,73 @@ describe('Testing app capability', () => { }); }); + describe('Testing hostToAppPerformanceMetricsHandler', () => { + it('app.registerHostToAppPerformanceMetricsHandler registered function should get called when api SDK call has timestamp', async () => { + await utils.initializeWithContext('content'); + utils.setRespondWithTimestamp(true); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handler).toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler registered function should not get called when api SDK call does not have timestamp', async () => { + await utils.initializeWithContext('content'); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handler).not.toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler registered function should not get called when api SDK call response has no info', async () => { + await utils.initializeWithContext('content'); + + const handler = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handler); + + // Call an sdk function such as getcontext + app.getContext(); + await utils.respondToMessage({ uuid: new UUID(), func: 'weirdFunc' }, {}); + expect(handler).not.toBeCalled(); + }); + + it('app.registerHostToAppPerformanceMetricsHandler should replace previously registered handler', async () => { + await utils.initializeWithContext('content'); + utils.setRespondWithTimestamp(true); + + const handlerOne = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handlerOne); + const handlerTwo = jest.fn(); + app.registerHostToAppPerformanceMetricsHandler(handlerTwo); + + // Call an sdk function such as getcontext + app.getContext(); + const getContextMessage = utils.findMessageByFunc('getContext'); + if (!getContextMessage) { + fail('Get context message was never created'); + } + await utils.respondToMessage(getContextMessage, {}); + expect(handlerTwo).toBeCalled(); + expect(handlerOne).not.toBeCalled(); + }); + }); + describe('Testing app.registerOnThemeChangeHandler function', () => { it('app.registerOnThemeChangeHandler should not allow calls before initialization', () => { // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/packages/teams-js/test/utils.ts b/packages/teams-js/test/utils.ts index bf84821a72..235059deef 100644 --- a/packages/teams-js/test/utils.ts +++ b/packages/teams-js/test/utils.ts @@ -39,11 +39,14 @@ export class Utils { public parentWindow: Window; public topWindow: Window; + public respondWithTimestamp: boolean; + private onMessageSent: null | ((messageRequest: MessageRequest) => void) = null; public constructor() { this.messages = []; this.childMessages = []; + this.respondWithTimestamp = false; this.parentWindow = { postMessage: (serializedMessage: SerializedMessageRequest, targetOrigin: string): void => { @@ -148,6 +151,10 @@ export class Utils { public processMessage: null | ((ev: MessageEvent) => Promise); + public setRespondWithTimestamp(respondWithTimestamp: boolean): void { + this.respondWithTimestamp = respondWithTimestamp; + } + public initializeWithContext = async ( frameContext: string, hostClientType: string = HostClientType.web, @@ -263,9 +270,10 @@ export class Utils { public respondToMessageWithPorts = async ( message: MessageRequest | NestedAppAuthRequest, - args: unknown[] = [], + args: unknown[], ports: MessagePort[] = [], ): Promise => { + const timestamp = this.respondWithTimestamp ? { timestamp: performance.now() + performance.timeOrigin } : {}; if (this.processMessage === null) { throw Error( `Cannot respond to message ${message.id} because processMessage function has not been set and is null`, @@ -276,6 +284,7 @@ export class Utils { id: message.id, uuidAsString: getMessageUUIDString(message), args: args, + ...timestamp, } as SerializedMessageResponse, ports, } as DOMMessageEvent; @@ -288,6 +297,7 @@ export class Utils { id: message.id, uuidAsString: getMessageUUIDString(message), args: args, + ...timestamp, } as SerializedMessageResponse, ports, } as unknown as MessageEvent);