Skip to content
Closed
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
28 changes: 28 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,34 @@ export const logMetrics = (metric: WidgetMetrics) => {
});
};

/**
* Identifies which watched props have changed between two objects.
* Only checks the keys specified in propsToWatch to avoid logging noise
* from frequently-changing props like timers.
*
* @param prev - The previous props object
* @param next - The next props object
* @param propsToWatch - Array of prop keys to monitor for changes
* @returns Record of changed prop keys with their old and new values, or null if no watched props changed
*/
export function getChangedWatchedProps(
prev: Record<string, any>,
next: Record<string, any>,
propsToWatch: string[]
): Record<string, {oldValue: any; newValue: any}> | null {
if (!propsToWatch.length || !prev || !next) return null;

const changes: Record<string, {oldValue: any; newValue: any}> = {};

for (const key of propsToWatch) {
if (prev[key] !== next[key]) {
changes[key] = {oldValue: prev[key], newValue: next[key]};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize watched prop values before logging metrics

getChangedWatchedProps stores raw oldValue/newValue objects, and withMetrics forwards that payload to logMetrics, which serializes with JSON.stringify. If a watched prop contains a circular or otherwise non-serializable value (common for SDK/model objects), JSON.stringify throws inside the effect, causing runtime errors instead of emitting metrics. This new PROPS_UPDATED path should sanitize or safely serialize watched values before calling logMetrics.

Useful? React with 👍 / 👎.

}
}

return Object.keys(changes).length > 0 ? changes : null;
}

/**
* Determines if props have changed between two objects using shallow comparison.
*
Expand Down
29 changes: 26 additions & 3 deletions packages/contact-center/ui-logging/src/withMetrics.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import React, {useEffect, useRef} from 'react';
import {havePropsChanged, logMetrics} from './metricsLogger';
import {getChangedWatchedProps, havePropsChanged, logMetrics} 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: (keyof P & string)[] = []
) {
return React.memo(
(props: P) => {
const prevPropsRef = useRef<P | null>(null);

useEffect(() => {
logMetrics({
widgetName,
Expand All @@ -20,7 +26,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
useEffect(() => {
if (prevPropsRef.current && propsToWatch.length > 0) {
const changes = getChangedWatchedProps(
prevPropsRef.current as Record<string, any>,
props as Record<string, any>,
Comment on lines +29 to +33

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit watched object prop changes even when memo skips render

This PROPS_UPDATED logic only runs inside an effect after the wrapped component renders, but withMetrics is still memoized with havePropsChanged, which treats object-to-object prop changes as unchanged. In practice, if a watched prop is an object (for example task or agent) and its reference changes between renders, React.memo can skip the render and this effect never runs, so the new feature silently misses updates for watched object props.

Useful? React with 👍 / 👎.

propsToWatch
);
if (changes) {
logMetrics({
widgetName,
event: 'PROPS_UPDATED',
props: changes,
timestamp: Date.now(),
});
}
}
prevPropsRef.current = props;
});

return <Component {...props} />;
},
Expand Down
158 changes: 157 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, getChangedWatchedProps, WidgetMetrics} from '../src/metricsLogger';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add more comprehensive tests


describe('metricsLogger', () => {
store.store.logger = {
Expand Down Expand Up @@ -82,5 +82,161 @@ describe('metricsLogger', () => {
expect(havePropsChanged(undefined, undefined)).toBe(false);
expect(havePropsChanged(null, undefined)).toBe(true);
});

it('should return false for the same object reference', () => {
const obj = {a: 1, b: 2};
expect(havePropsChanged(obj, obj)).toBe(false);
});

it('should return true when primitive value changes in flat object', () => {
const prev = {name: 'John', age: 30};
const next = {name: 'John', age: 31};
expect(havePropsChanged(prev, next)).toBe(true);
});

it('should return false for objects with same primitive values', () => {
const prev = {name: 'John', age: 30};
const next = {name: 'John', age: 30};
expect(havePropsChanged(prev, next)).toBe(false);
});

it('should return true when a value changes from object to null', () => {
const prev = {a: {nested: true}};
const next = {a: null};
expect(havePropsChanged(prev, next)).toBe(true);
});

it('should return true when a value changes from null to object', () => {
const prev = {a: null};
const next = {a: {nested: true}};
expect(havePropsChanged(prev, next)).toBe(true);
});

it('should return true when next has more keys than prev', () => {
const prev = {a: 1};
const next = {a: 1, b: 2};
expect(havePropsChanged(prev, next)).toBe(true);
});

it('should handle empty objects', () => {
expect(havePropsChanged({}, {})).toBe(false);
});

it('should return false when both arrays are different references but same nested objects', () => {
const prev = {items: [1, 2, 3]};
const next = {items: [1, 2, 4]};
expect(havePropsChanged(prev, next)).toBe(false);
});

it('should return true when function references differ', () => {
const prev = {onClick: () => {}};
const next = {onClick: () => {}};
expect(havePropsChanged(prev, next)).toBe(true);
});

it('should return false when function reference is the same', () => {
const fn = () => {};
const prev = {onClick: fn};
const next = {onClick: fn};
expect(havePropsChanged(prev, next)).toBe(false);
});

it('should return true when a primitive changes to undefined', () => {
const prev = {a: 'hello'};
const next = {a: undefined};
expect(havePropsChanged(prev, next)).toBe(true);
});
});

describe('getChangedWatchedProps', () => {
it('should return null when propsToWatch is empty', () => {
expect(getChangedWatchedProps({a: 1}, {a: 2}, [])).toBeNull();
});

it('should return null when prev or next is null/undefined', () => {
expect(getChangedWatchedProps(null, {a: 1}, ['a'])).toBeNull();
expect(getChangedWatchedProps({a: 1}, null, ['a'])).toBeNull();
});

it('should return null when watched props have not changed', () => {
const prev = {name: 'John', timer: 10, age: 30};
const next = {name: 'John', timer: 20, age: 30};
expect(getChangedWatchedProps(prev, next, ['name', 'age'])).toBeNull();
});

it('should return changes for watched props that changed', () => {
const prev = {name: 'John', timer: 10, age: 30};
const next = {name: 'Jane', timer: 20, age: 31};
const result = getChangedWatchedProps(prev, next, ['name', 'age']);
expect(result).toEqual({
name: {oldValue: 'John', newValue: 'Jane'},
age: {oldValue: 30, newValue: 31},
});
});

it('should only report changes for watched props, ignoring unwatched', () => {
const prev = {name: 'John', timer: 10};
const next = {name: 'John', timer: 20};
expect(getChangedWatchedProps(prev, next, ['name'])).toBeNull();
});

it('should handle watched props that do not exist on objects', () => {
const prev = {name: 'John'};
const next = {name: 'John'};
expect(getChangedWatchedProps(prev, next, ['name', 'missing'])).toBeNull();
});

it('should detect when a watched prop changes from undefined to a value', () => {
const prev = {name: undefined};
const next = {name: 'John'};
const result = getChangedWatchedProps(prev, next, ['name']);
expect(result).toEqual({
name: {oldValue: undefined, newValue: 'John'},
});
});

it('should detect when a watched prop changes from a value to undefined', () => {
const prev = {name: 'John'};
const next = {name: undefined};
const result = getChangedWatchedProps(prev, next, ['name']);
expect(result).toEqual({
name: {oldValue: 'John', newValue: undefined},
});
});

it('should return only the changed watched prop when multiple are watched', () => {
const prev = {name: 'John', status: 'active', role: 'admin'};
const next = {name: 'John', status: 'inactive', role: 'admin'};
const result = getChangedWatchedProps(prev, next, ['name', 'status', 'role']);
expect(result).toEqual({
status: {oldValue: 'active', newValue: 'inactive'},
});
});

it('should detect changes for boolean watched props', () => {
const prev = {isActive: true, name: 'John'};
const next = {isActive: false, name: 'John'};
const result = getChangedWatchedProps(prev, next, ['isActive']);
expect(result).toEqual({
isActive: {oldValue: true, newValue: false},
});
});

it('should detect changes for numeric watched props', () => {
const prev = {count: 0, name: 'John'};
const next = {count: 5, name: 'John'};
const result = getChangedWatchedProps(prev, next, ['count']);
expect(result).toEqual({
count: {oldValue: 0, newValue: 5},
});
});

it('should return null when both prev and next are null', () => {
expect(getChangedWatchedProps(null, null, ['a'])).toBeNull();
});

it('should return null when both prev and next are undefined', () => {
expect(getChangedWatchedProps(undefined, undefined, ['a'])).toBeNull();
});
});
});
Loading