diff --git a/packages/contact-center/ui-logging/src/index.ts b/packages/contact-center/ui-logging/src/index.ts index 522b06d0b..1c7c23bff 100644 --- a/packages/contact-center/ui-logging/src/index.ts +++ b/packages/contact-center/ui-logging/src/index.ts @@ -1,5 +1,5 @@ import withMetrics from './withMetrics'; -import {WidgetMetrics} from './metricsLogger'; +import {WidgetMetrics, logPropsUpdated} from './metricsLogger'; -export {withMetrics}; +export {withMetrics, logPropsUpdated}; export type {WidgetMetrics}; diff --git a/packages/contact-center/ui-logging/src/metricsLogger.ts b/packages/contact-center/ui-logging/src/metricsLogger.ts index 02f2b8d87..edf9492c6 100644 --- a/packages/contact-center/ui-logging/src/metricsLogger.ts +++ b/packages/contact-center/ui-logging/src/metricsLogger.ts @@ -44,6 +44,65 @@ export const logMetrics = (metric: WidgetMetrics) => { }); }; +/** + * Logs a PROPS_UPDATED event when specific props have changed. + * + * This function checks if any of the specified props have changed between + * the previous and next props objects. It performs a shallow comparison + * for each watched prop and only logs if at least one has changed. + * + * @param widgetName - Name of the widget generating the metric + * @param propsToWatch - Array of prop names to monitor for changes + * @param prevProps - The previous props object + * @param nextProps - The next props object to compare against + * + * @example + * ```typescript + * // Only log when 'status' or 'taskId' props change + * logPropsUpdated('CallControl', ['status', 'taskId'], oldProps, newProps); + * ``` + */ +export const logPropsUpdated = ( + widgetName: string, + propsToWatch: string[], + prevProps: Record, + nextProps: Record +): void => { + if (!propsToWatch || propsToWatch.length === 0) { + return; + } + + // Check if any watched props have changed + const changedProps: Record = {}; + let hasChanges = false; + + for (const propName of propsToWatch) { + const prevVal = prevProps[propName]; + const nextVal = nextProps[propName]; + + // Shallow comparison for watched prop + if (prevVal !== nextVal) { + hasChanges = true; + changedProps[propName] = { + prev: prevVal, + next: nextVal, + }; + } + } + + // Only log if at least one watched prop changed + if (hasChanges) { + logMetrics({ + widgetName, + event: 'PROPS_UPDATED', + timestamp: Date.now(), + additionalContext: { + changedProps, + }, + }); + } +}; + /** * Determines if props have changed between two objects using shallow comparison. * diff --git a/packages/contact-center/ui-logging/src/withMetrics.tsx b/packages/contact-center/ui-logging/src/withMetrics.tsx index 77354d3dd..ac38a550e 100644 --- a/packages/contact-center/ui-logging/src/withMetrics.tsx +++ b/packages/contact-center/ui-logging/src/withMetrics.tsx @@ -1,9 +1,16 @@ import React, {useEffect, useRef} from 'react'; -import {havePropsChanged, logMetrics} from './metricsLogger'; +import {havePropsChanged, logMetrics, logPropsUpdated} from './metricsLogger'; -export default function withMetrics

(Component: any, widgetName: string) { +export default function withMetrics

( + Component: any, + widgetName: string, + propsToWatch?: string[] +) { return React.memo( (props: P) => { + const prevPropsRef = useRef

(undefined); + const isFirstRenderRef = useRef(true); + useEffect(() => { logMetrics({ widgetName, @@ -20,7 +27,24 @@ export default function withMetrics

(Component: any, widgetName }; }, []); - // TODO: https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6890 PROPS_UPDATED event + // Track props updates for watched props + // We need to manually track prop changes since useEffect with [props] doesn't work well with object refs + if (!isFirstRenderRef.current && propsToWatch && propsToWatch.length > 0 && prevPropsRef.current) { + logPropsUpdated( + widgetName, + propsToWatch, + prevPropsRef.current as Record, + props as Record + ); + } + + // Update refs after render + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + } + prevPropsRef.current = props; + }); return ; }, diff --git a/packages/contact-center/ui-logging/tests/metricsLogger.test.ts b/packages/contact-center/ui-logging/tests/metricsLogger.test.ts index f7ecf15e5..cba1d832c 100644 --- a/packages/contact-center/ui-logging/tests/metricsLogger.test.ts +++ b/packages/contact-center/ui-logging/tests/metricsLogger.test.ts @@ -1,5 +1,5 @@ import store from '@webex/cc-store'; -import {logMetrics, havePropsChanged, WidgetMetrics} from '../src/metricsLogger'; +import {logMetrics, havePropsChanged, logPropsUpdated, WidgetMetrics} from '../src/metricsLogger'; describe('metricsLogger', () => { store.store.logger = { @@ -83,4 +83,118 @@ describe('metricsLogger', () => { expect(havePropsChanged(null, undefined)).toBe(true); }); }); + + describe('logPropsUpdated', () => { + beforeEach(() => { + store.store.logger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + }; + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should log PROPS_UPDATED event when watched props change', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const prevProps = {status: 'idle', taskId: '123', timer: 100}; + const nextProps = {status: 'active', taskId: '123', timer: 200}; + const propsToWatch = ['status', 'taskId']; + + logPropsUpdated('TestWidget', propsToWatch, prevProps, nextProps); + + expect(store.logger.log).toHaveBeenCalledWith( + expect.stringContaining('PROPS_UPDATED'), + expect.objectContaining({ + module: 'metricsLogger.tsx', + method: 'logMetrics', + }) + ); + + const logCall = (store.logger.log as jest.Mock).mock.calls[0][0]; + const loggedMetric = JSON.parse(logCall.replace('CC-Widgets: UI Metrics: ', '')); + + expect(loggedMetric.widgetName).toBe('TestWidget'); + expect(loggedMetric.event).toBe('PROPS_UPDATED'); + expect(loggedMetric.timestamp).toBe(mockTime); + expect(loggedMetric.additionalContext.changedProps).toEqual({ + status: {prev: 'idle', next: 'active'}, + }); + }); + + it('should not log when watched props have not changed', () => { + const prevProps = {status: 'idle', taskId: '123', timer: 100}; + const nextProps = {status: 'idle', taskId: '123', timer: 200}; + const propsToWatch = ['status', 'taskId']; + + logPropsUpdated('TestWidget', propsToWatch, prevProps, nextProps); + + expect(store.logger.log).not.toHaveBeenCalled(); + }); + + it('should not log when propsToWatch is empty', () => { + const prevProps = {status: 'idle', taskId: '123'}; + const nextProps = {status: 'active', taskId: '456'}; + + logPropsUpdated('TestWidget', [], prevProps, nextProps); + + expect(store.logger.log).not.toHaveBeenCalled(); + }); + + it('should not log when propsToWatch is undefined', () => { + const prevProps = {status: 'idle', taskId: '123'}; + const nextProps = {status: 'active', taskId: '456'}; + + logPropsUpdated('TestWidget', undefined as any, prevProps, nextProps); + + expect(store.logger.log).not.toHaveBeenCalled(); + }); + + it('should track multiple prop changes', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const prevProps = {status: 'idle', taskId: '123', name: 'Test'}; + const nextProps = {status: 'active', taskId: '456', name: 'Updated'}; + const propsToWatch = ['status', 'taskId', 'name']; + + logPropsUpdated('TestWidget', propsToWatch, prevProps, nextProps); + + const logCall = (store.logger.log as jest.Mock).mock.calls[0][0]; + const loggedMetric = JSON.parse(logCall.replace('CC-Widgets: UI Metrics: ', '')); + + expect(loggedMetric.additionalContext.changedProps).toEqual({ + status: {prev: 'idle', next: 'active'}, + taskId: {prev: '123', next: '456'}, + name: {prev: 'Test', next: 'Updated'}, + }); + }); + + it('should ignore props not in propsToWatch list', () => { + const mockTime = 1234567890; + jest.setSystemTime(mockTime); + + const prevProps = {status: 'idle', taskId: '123', timer: 100, internalState: 'foo'}; + const nextProps = {status: 'active', taskId: '123', timer: 200, internalState: 'bar'}; + const propsToWatch = ['status']; + + logPropsUpdated('TestWidget', propsToWatch, prevProps, nextProps); + + const logCall = (store.logger.log as jest.Mock).mock.calls[0][0]; + const loggedMetric = JSON.parse(logCall.replace('CC-Widgets: UI Metrics: ', '')); + + expect(loggedMetric.additionalContext.changedProps).toEqual({ + status: {prev: 'idle', next: 'active'}, + }); + expect(loggedMetric.additionalContext.changedProps.timer).toBeUndefined(); + expect(loggedMetric.additionalContext.changedProps.internalState).toBeUndefined(); + }); + }); }); diff --git a/packages/contact-center/ui-logging/tests/withMetrics.test.tsx b/packages/contact-center/ui-logging/tests/withMetrics.test.tsx index 23ec71691..0b3636753 100644 --- a/packages/contact-center/ui-logging/tests/withMetrics.test.tsx +++ b/packages/contact-center/ui-logging/tests/withMetrics.test.tsx @@ -102,4 +102,114 @@ describe('withMetrics HOC', () => { rerender(); expect(renderSpy).toHaveBeenCalledTimes(2); }); + + it('should log PROPS_UPDATED when watched props change', () => { + const renderSpy = jest.fn(); + const SpyComponent: React.FC = (props) => { + renderSpy(); + return

Test Component {props.name}
; + }; + + const WrappedComponentWithProps = withMetrics(SpyComponent, 'TestWidget', ['name', 'status']); + + const {rerender} = render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + + // Clear mount log and render spy + (store.logger.log as jest.Mock).mockClear(); + renderSpy.mockClear(); + + // Re-render with changed watched prop + rerender(); + + // Component should re-render + expect(renderSpy).toHaveBeenCalledTimes(1); + + // Should log PROPS_UPDATED via store.logger.log + const logCalls = (store.logger.log as jest.Mock).mock.calls; + const propsUpdatedCall = logCalls.find((call) => call[0].includes('PROPS_UPDATED')); + + expect(propsUpdatedCall).toBeDefined(); + const loggedMetric = JSON.parse(propsUpdatedCall[0].replace('CC-Widgets: UI Metrics: ', '')); + expect(loggedMetric).toMatchObject({ + widgetName: 'TestWidget', + event: 'PROPS_UPDATED', + additionalContext: { + changedProps: { + name: {prev: 'test', next: 'updated'}, + }, + }, + }); + }); + + it('should not log PROPS_UPDATED when unwatched props change', () => { + const WrappedComponent = withMetrics(TestComponent, 'TestWidget', ['name']); + + const {rerender} = render(); + + // Clear mount log + logMetricsSpy.mockClear(); + + // Re-render with only unwatched props changed + rerender(); + + // Should not log PROPS_UPDATED since watched props haven't changed + expect(logMetricsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: 'PROPS_UPDATED', + }) + ); + }); + + it('should not log PROPS_UPDATED when no propsToWatch specified', () => { + const WrappedComponent = withMetrics(TestComponent, 'TestWidget'); + + const {rerender} = render(); + + // Clear mount log + logMetricsSpy.mockClear(); + + // Re-render with changed props + rerender(); + + // Should not log PROPS_UPDATED + expect(logMetricsSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: 'PROPS_UPDATED', + }) + ); + }); + + it('should track multiple watched prop changes', () => { + const WrappedComponent = withMetrics(TestComponent, 'TestWidget', ['name', 'status', 'count']); + + const {rerender} = render(); + + // Clear mount log + (store.logger.log as jest.Mock).mockClear(); + + // Re-render with multiple watched props changed + rerender(); + + // Should log all changed watched props via store.logger.log + const logCalls = (store.logger.log as jest.Mock).mock.calls; + const propsUpdatedCall = logCalls.find((call) => call[0].includes('PROPS_UPDATED')); + + expect(propsUpdatedCall).toBeDefined(); + const loggedMetric = JSON.parse(propsUpdatedCall[0].replace('CC-Widgets: UI Metrics: ', '')); + expect(loggedMetric).toMatchObject({ + widgetName: 'TestWidget', + event: 'PROPS_UPDATED', + additionalContext: { + changedProps: { + name: {prev: 'test', next: 'updated'}, + status: {prev: 'idle', next: 'active'}, + count: {prev: 1, next: 2}, + }, + }, + }); + + // Verify timer is not included since it's not watched + expect(loggedMetric.additionalContext.changedProps.timer).toBeUndefined(); + }); });