Skip to content

DEVPROD-22466 Add analytics indicating how long a user looked at a page.#1205

Merged
khelif96 merged 34 commits intoevergreen-ci:mainfrom
khelif96:DEVPROD-22466-2
Apr 14, 2026
Merged

DEVPROD-22466 Add analytics indicating how long a user looked at a page.#1205
khelif96 merged 34 commits intoevergreen-ci:mainfrom
khelif96:DEVPROD-22466-2

Conversation

@khelif96
Copy link
Copy Markdown
Contributor

DEVPROD-22466

Description

In an effort to measure how long people spend performing investigations this pr add some new analytics events which track how long a user spends on an individual page.

@khelif96 khelif96 requested a review from a team as a code owner November 25, 2025 22:00
@khelif96 khelif96 added spruce parsley lib Updates to the @evg-ui/lib package labels Nov 25, 2025
});
}
};
}, [enabled, sendEvent, trackSession, minDuration]);

This comment was marked as outdated.

document.removeEventListener("visibilitychange", handleVisibilityChange);

// Track session end
if (trackSession && stateChangeCount.current > 0) {

This comment was marked as outdated.

* });
* ```
*/
export const usePageVisibilityAnalytics = ({
Copy link
Copy Markdown
Contributor

@sophstad sophstad Dec 1, 2025

Choose a reason for hiding this comment

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

I think it would be preferable if we could use this hook without instantiating it on every page; it seems like it would be easy to forget to add this in the future. Do you think there's any way of doing that?

Also, leveraging the route_name param instead of the analytics object may be more helpful from a usability perspective. Is there any reason we must use identifier? At the very least, maybe omitting it would allow instantiating tracking in one parent component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I removed it and its now based on route names and only instantiated at the root. We do however lose page specific metadata by only instantiating once. Since we can't inject things like project identifier.

@khelif96 khelif96 requested a review from sophstad December 31, 2025 22:07
stateChangeCount.current = 0;
lastVisibilityState.current = null;
};
}, [enabled, sendEvent, trackSession, minDuration, pathname]);

This comment was marked as outdated.

Comment on lines +112 to +115
if (duration < minDuration) {
visibilityStartTime.current = currentTime;
return;
}

This comment was marked as outdated.

"visibility.timestamp": string;
};

interface UsePageVisibilityAnalyticsOptions {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] Preference to omit use from the typename

Suggested change
interface UsePageVisibilityAnalyticsOptions {
interface PageVisibilityAnalyticsOptions {

Could you also remove the field comments?

* Helps avoid tracking rapid tab switches
* @default 1000
*/
minDuration?: number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] Prefer minDurationMs

"visibility.total_visible_ms": number;
"visibility.total_hidden_ms": number;
"visibility.state_changes": number;
"visibility.timestamp": string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think we should include timestamp on any of these since Honeycomb already attaches that data to a trace


type PageVisibilityAction =
| {
name: "System Event page became visible";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Naming nit... what's the idea behind "System Event" prefixing these?

lastVisibilityState.current = null;
};
}, [enabled, sendEvent, trackSession, minDuration, pathname]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I imagine the intent by using pathname as the dependency along with minDuration is to avoid query param tracking, but that doesn't seem to be working. Here are some duplicate events from a task's page automatically appended query params, I think we should only aim to log one:

Image

(trace 1, trace 2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The query param issue is gone, but I'm seeing double logging now because of things like a redirect from root -> user patches -> my user patches. Here's a good timeline. I imagine that could be kind of tricky to solve, but I do think it decreases the usefulness

Comment on lines +34 to +43
/**
* Whether to track the visibility changes
* @default true
*/
enabled?: boolean;
/**
* Whether to track session start/end events
* @default true
*/
trackSession?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What are the use cases for these?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed track session

name: "System Event page visibility session ended";
"visibility.total_visible_ms": number;
"visibility.total_hidden_ms": number;
"visibility.state_changes": number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I might prefer to call this visibility_changes to clarify it's not referring to page navigation

"visibility.timestamp": string;
}
| {
name: "System Event page visibility session started";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] Could call this "Session started" since it's in the PageVisibility analytics object, your call

"visibility.timestamp": string;
}
| {
name: "System Event page visibility session ended";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ditto

}

try {
sendEvent({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would it be possible to send all of these as spans within a trace 😳 I think that would be wayyyy more useful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Theoretically its possible but we would end up with a ginormous trace which may be less useful. (Thinking potential multi hour traces.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good point—I just found this timeline view (linked by session ID) which totally paints the same picture I was hoping for https://ui.honeycomb.io/mongodb-4b/environments/staging/session-timeline-experiment/f3c2231532c0bb2ed5492a608151770e 👍

@khelif96 khelif96 requested a review from sophstad February 5, 2026 22:58
lastVisibilityState.current = null;
};
}, [enabled, sendEvent, trackSession, minDuration, pathname]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The query param issue is gone, but I'm seeing double logging now because of things like a redirect from root -> user patches -> my user patches. Here's a good timeline. I imagine that could be kind of tricky to solve, but I do think it decreases the usefulness

"visibility.duration_ms": number;
}
| {
name: "System Event page hidden";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to be clear, navigating to a new page should fire a page hidden event and then a new page visible event, right? That's what I'm observing

Copy link
Copy Markdown
Contributor Author

@khelif96 khelif96 Feb 26, 2026

Choose a reason for hiding this comment

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

Good catch fixed, It should have also fixed the other issue

@khelif96 khelif96 requested a review from sophstad February 26, 2026 15:23

try {
sendEvent({
name: "System Event session ended",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I fear I haven't been able to produce this event during this iteration, but I was on the previous review 🙁 honeycomb

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Apparently this isn't super reliable and browsers don't consistently allow enough time for the component to unmount do you have any thoughts on how we should handle this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think removing pathname from the useEffect is the reason this no longer works (but that's still the right move imo). Claude had these two suggestions:

  1. Use visibilitychange + navigator.sendBeacon() (most reliable)
    The visibilitychange event with state "hidden" fires reliably on page unload in all modern browsers (it's part of the Page Lifecycle API). Combine it with navigator.sendBeacon(), which is specifically designed to guarantee delivery during page teardown. This would mean sending the session-ended telemetry outside of the OTel span pipeline, directly as a beacon to the Honeycomb endpoint.
  2. Use pagehide event as a session-end signal
    Register a pagehide listener (in the effect setup, not in cleanup) that sends the session summary. pagehide fires more reliably than relying on React unmount.

Comment on lines +53 to +56
const { sendEvent } = useAnalyticsRoot<PageVisibilityAction, string>(
"PageVisibility",
stableAttributes,
);

This comment was marked as outdated.

The default parameter `attributes = {}` created a new object on every
render, causing useCallback to return a new sendEvent each cycle. This
triggered spurious session-ended/session-started analytics events in
usePageVisibilityAnalytics. Use a module-level constant instead.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@khelif96
Copy link
Copy Markdown
Contributor Author

evergreen retry

@khelif96 khelif96 requested a review from sophstad March 24, 2026 18:26

try {
sendEvent({
name: "System Event session ended",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think removing pathname from the useEffect is the reason this no longer works (but that's still the right move imo). Claude had these two suggestions:

  1. Use visibilitychange + navigator.sendBeacon() (most reliable)
    The visibilitychange event with state "hidden" fires reliably on page unload in all modern browsers (it's part of the Page Lifecycle API). Combine it with navigator.sendBeacon(), which is specifically designed to guarantee delivery during page teardown. This would mean sending the session-ended telemetry outside of the OTel span pipeline, directly as a beacon to the Honeycomb endpoint.
  2. Use pagehide event as a session-end signal
    Register a pagehide listener (in the effect setup, not in cleanup) that sends the session summary. pagehide fires more reliably than relying on React unmount.

@khelif96
Copy link
Copy Markdown
Contributor Author

@sophstad I think this should do it.

image

This canvas shows some of the insights I was hoping we can demonstrate with this change

@khelif96 khelif96 requested a review from sophstad April 14, 2026 16:58
Comment thread packages/lib/src/analytics/hooks/usePageVisibilityAnalytics.ts
Copy link
Copy Markdown
Contributor

@sophstad sophstad left a comment

Choose a reason for hiding this comment

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

at last!! 🥳

@khelif96 khelif96 enabled auto-merge (squash) April 14, 2026 20:14
@khelif96 khelif96 merged commit 49bfda9 into evergreen-ci:main Apr 14, 2026
19 checks passed
@khelif96 khelif96 deleted the DEVPROD-22466-2 branch April 14, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib Updates to the @evg-ui/lib package parsley spruce

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants