Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ const initSegmentAnalytics = () => {
options.integrations = { 'Segment.io': { apiHost: TELEMETRY_API_HOST } };
}
analytics.load(TELEMETRY_API_KEY, options);
analytics.page(); // Make the first page call to load the integrations
};

if (!SAMPLE_SESSION) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { testHook } from '@console/shared/src/test-utils/hooks-utils';
import {
getClusterProperties,
updateClusterPropertiesFromTests,
clearTelemetryEventsForTests,
useTelemetry,
} from '../useTelemetry';

Expand Down Expand Up @@ -135,6 +136,7 @@ describe('useTelemetry', () => {

beforeEach(() => {
listener.mockReset();
clearTelemetryEventsForTests();
const extensions: ResolvedExtension<TelemetryListener>[] = [
{
type: 'console.telemetry/listener',
Expand Down
48 changes: 37 additions & 11 deletions frontend/packages/console-shared/src/hooks/useTelemetry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import {
useResolvedExtensions,
isTelemetryListener,
Expand Down Expand Up @@ -70,6 +70,10 @@ let clusterProperties = getClusterProperties();

export const updateClusterPropertiesFromTests = () => (clusterProperties = getClusterProperties());

export const clearTelemetryEventsForTests = () => {
telemetryEvents = [];
};
Comment on lines +73 to +75
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix useTelemetry callback signature: properties must be optional (matches TelemetryEventListener + existing call sites)

TelemetryEventListener allows properties?, and callers (including this repo’s tests) invoke fireTelemetryEvent('event') without a second arg. The current implementation requires properties: Record<string, any>, which is not type-compatible and can break builds depending on TS settings.

 return useCallback<TelemetryEventListener>(
-  (eventType, properties: Record<string, any>) => {
+  (eventType, properties?: Record<string, any>) => {
+    const safeProps = properties ?? {};
     const currentUserPreference = userPreferenceRef.current;
     const currentExtensions = extensionsRef.current;
     const currentUserResource = userResourceRef.current;
     const currentUserResourceIsLoaded = userResourceIsLoadedRef.current;

     if (isOptedOutFromTelemetry(currentUserPreference)) {
       return;
     }

     const event = {
       ...clusterProperties,
-      ...properties,
+      ...safeProps,
       // This is required to ensure that the replayed events uses the right path.
-      path: properties?.pathname,
+      path: safeProps.pathname,
     };

     if (
-      (clusterIsOptedInToTelemetry() && !currentUserPreference) ||
+      (clusterIsOptedInToTelemetry() && !currentUserPreference) ||
       !currentUserResourceIsLoaded
     ) {
       telemetryEvents.push({ eventType, event });
       if (telemetryEvents.length > 10) {
         telemetryEvents.shift();
       }
       return;
     }

     currentExtensions.forEach((e) =>
       e.properties.listener(eventType, { ...event, userResource: currentUserResource }),
     );
   },
   [],
 );

Also applies to: 118-156

🤖 Prompt for AI Agents
In frontend/packages/console-shared/src/hooks/useTelemetry.ts around lines 73-75
and also affecting lines 118-156, the telemetry callback signature requires
properties: Record<string, any> but callers (and TelemetryEventListener) pass
only the event name; change the signature so properties is optional
(properties?: Record<string, any>), and update any corresponding function
declarations and invocations within the file to accept an undefined properties
(or provide a default) so calls like fireTelemetryEvent('event') remain valid
and type-compatible.


export const useTelemetry = () => {
// TODO use usePluginInfo() hook to tell whether all dynamic plugins have been processed
// to avoid firing telemetry events multiple times whenever a dynamic plugin loads asynchronously
Expand All @@ -85,24 +89,44 @@ export const useTelemetry = () => {

const [extensions] = useResolvedExtensions<TelemetryListener>(isTelemetryListener);

// Store current values in refs so the callback can access them without being recreated
const extensionsRef = useRef(extensions);
extensionsRef.current = extensions;
const userPreferenceRef = useRef(currentUserPreferenceTelemetryValue);
userPreferenceRef.current = currentUserPreferenceTelemetryValue;
const userResourceRef = useRef(userResource);
userResourceRef.current = userResource;
const userResourceIsLoadedRef = useRef(userResourceIsLoaded);
userResourceIsLoadedRef.current = userResourceIsLoaded;

// Replay queued events when user explicitly opts in (OPT-IN cluster mode only)
useEffect(() => {
if (
userIsOptedInToTelemetry(currentUserPreferenceTelemetryValue) &&
clusterIsOptedInToTelemetry() &&
telemetryEvents.length > 0 &&
userResourceIsLoaded
extensions.length > 0 &&
userResourceIsLoaded &&
telemetryEvents.length > 0
) {
telemetryEvents.forEach(({ eventType, event }) => {
extensions.forEach((e) => e.properties.listener(eventType, { ...event, userResource }));
});
telemetryEvents = [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUserPreferenceTelemetryValue, userResourceIsLoaded]);
}, [currentUserPreferenceTelemetryValue, userResourceIsLoaded, extensions, userResource]);

// Return a stable callback that reads current values from refs
// This prevents consumers from re-running effects when telemetry state changes
return useCallback<TelemetryEventListener>(
(eventType, properties: Record<string, any>) => {
if (isOptedOutFromTelemetry(currentUserPreferenceTelemetryValue)) return;
const currentUserPreference = userPreferenceRef.current;
const currentExtensions = extensionsRef.current;
const currentUserResource = userResourceRef.current;
const currentUserResourceIsLoaded = userResourceIsLoadedRef.current;

if (isOptedOutFromTelemetry(currentUserPreference)) {
return;
}

const event = {
...clusterProperties,
Expand All @@ -112,20 +136,22 @@ export const useTelemetry = () => {
};

if (
(clusterIsOptedInToTelemetry() && !currentUserPreferenceTelemetryValue) ||
!userResourceIsLoaded
(clusterIsOptedInToTelemetry() && !currentUserPreference) ||
!currentUserResourceIsLoaded
) {
telemetryEvents.push({ eventType, event });

if (telemetryEvents.length > 10) {
telemetryEvents.shift(); // Remove the first element
telemetryEvents.shift();
}

return;
}

extensions.forEach((e) => e.properties.listener(eventType, { ...event, userResource }));
currentExtensions.forEach((e) =>
e.properties.listener(eventType, { ...event, userResource: currentUserResource }),
);
},
[extensions, currentUserPreferenceTelemetryValue, userResource, userResourceIsLoaded],
[], // Empty deps - callback is stable, reads current values from refs
);
};
31 changes: 23 additions & 8 deletions frontend/public/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,26 +360,30 @@ const AppRouter: React.FC = () => {
const CaptureTelemetry = memo(function CaptureTelemetry() {
const [perspective] = useActivePerspective();
const fireTelemetryEvent = useTelemetry();
const [debounceTime, setDebounceTime] = useState(5000);
const [titleOnLoad, setTitleOnLoad] = useState('');
// notify of identity change
const user = useSelector(getUser);
const telemetryTitle = getTelemetryTitle();

// Track if we've already fired the initial page event
const initialPageFiredRef = useRef(false);

// Wait 5 seconds before firing the first page event to allow initial redirects to settle
useEffect(() => {
setTimeout(() => {
setTitleOnLoad(telemetryTitle);
setDebounceTime(500);
}, 5000);
}, [telemetryTitle]);

useEffect(() => {
if (user?.uid || user?.username) {
fireTelemetryEvent('identify', { perspective, user });
}
// Only trigger identify event when the user identifier changes
// Only trigger identify event when the user identifier changes.
// fireTelemetryEvent is stable (doesn't change identity) so it's safe to omit.
// We intentionally exclude perspective/user object - identify is for user identity, not navigation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.uid || user?.username, fireTelemetryEvent]);
}, [user?.uid, user?.username]);

// notify url change events
// Debouncing the url change events so that redirects don't fire multiple events.
Expand All @@ -390,25 +394,36 @@ const CaptureTelemetry = memo(function CaptureTelemetry() {
title: getTelemetryTitle(),
...withoutSensitiveInformations(location),
});
}, debounceTime);
}, 500);

// Use a ref to always have the latest fireUrlChangeEvent without triggering effect re-runs
const fireUrlChangeEventRef = useRef(fireUrlChangeEvent);
fireUrlChangeEventRef.current = fireUrlChangeEvent;

useEffect(() => {
if (!titleOnLoad) {
return;
}
fireUrlChangeEvent(history.location);
// Only fire initial page event once
if (!initialPageFiredRef.current) {
initialPageFiredRef.current = true;
fireUrlChangeEventRef.current(history.location);
}
let { pathname, search } = history.location;
const unlisten = history.listen((location) => {
const { pathname: nextPathname, search: nextSearch } = history.location;
if (pathname !== nextPathname || search !== nextSearch) {
pathname = nextPathname;
search = nextSearch;
fireUrlChangeEvent(location);
fireUrlChangeEventRef.current(location);
}
});
return () => {
unlisten();
};
}, [perspective, fireUrlChangeEvent, titleOnLoad]);
// fireUrlChangeEventRef is stable, so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [perspective, titleOnLoad]);

return null;
});
Expand Down