Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/contact-center/ui-logging/src/index.ts
Original file line number Diff line number Diff line change
@@ -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};
59 changes: 59 additions & 0 deletions packages/contact-center/ui-logging/src/metricsLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
nextProps: Record<string, any>
): void => {
if (!propsToWatch || propsToWatch.length === 0) {
return;
}

// Check if any watched props have changed
const changedProps: Record<string, {prev: any; next: any}> = {};
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.
*
Expand Down
30 changes: 27 additions & 3 deletions packages/contact-center/ui-logging/src/withMetrics.tsx
Original file line number Diff line number Diff line change
@@ -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<P extends object>(Component: any, widgetName: string) {
export default function withMetrics<P extends object>(
Component: any,
widgetName: string,
propsToWatch?: string[]
) {
return React.memo(
(props: P) => {
const prevPropsRef = useRef<P | undefined>(undefined);
const isFirstRenderRef = useRef(true);

useEffect(() => {
logMetrics({
widgetName,
Expand All @@ -20,7 +27,24 @@ export default function withMetrics<P extends object>(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<string, any>,
props as Record<string, any>
);
}

// Update refs after render
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
}
prevPropsRef.current = props;
});

return <Component {...props} />;
},
Expand Down
116 changes: 115 additions & 1 deletion packages/contact-center/ui-logging/tests/metricsLogger.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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();
});
});
});
110 changes: 110 additions & 0 deletions packages/contact-center/ui-logging/tests/withMetrics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,114 @@ describe('withMetrics HOC', () => {
rerender(<WrappedSpy name="different" />);
expect(renderSpy).toHaveBeenCalledTimes(2);
});

it('should log PROPS_UPDATED when watched props change', () => {
const renderSpy = jest.fn();
const SpyComponent: React.FC<TestComponentProps> = (props) => {
renderSpy();
return <div>Test Component {props.name}</div>;
};

const WrappedComponentWithProps = withMetrics<TestComponentProps>(SpyComponent, 'TestWidget', ['name', 'status']);

const {rerender} = render(<WrappedComponentWithProps name="test" status="idle" />);
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(<WrappedComponentWithProps name="updated" status="idle" />);

// 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<TestComponentProps>(TestComponent, 'TestWidget', ['name']);

const {rerender} = render(<WrappedComponent name="test" status="idle" timer={100} />);

// Clear mount log
logMetricsSpy.mockClear();

// Re-render with only unwatched props changed
rerender(<WrappedComponent name="test" status="active" timer={200} />);

// 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<TestComponentProps>(TestComponent, 'TestWidget');

const {rerender} = render(<WrappedComponent name="test" />);

// Clear mount log
logMetricsSpy.mockClear();

// Re-render with changed props
rerender(<WrappedComponent name="updated" />);

// Should not log PROPS_UPDATED
expect(logMetricsSpy).not.toHaveBeenCalledWith(
expect.objectContaining({
event: 'PROPS_UPDATED',
})
);
});

it('should track multiple watched prop changes', () => {
const WrappedComponent = withMetrics<TestComponentProps>(TestComponent, 'TestWidget', ['name', 'status', 'count']);

const {rerender} = render(<WrappedComponent name="test" status="idle" count={1} timer={100} />);

// Clear mount log
(store.logger.log as jest.Mock).mockClear();

// Re-render with multiple watched props changed
rerender(<WrappedComponent name="updated" status="active" count={2} timer={200} />);

// 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();
});
});