Only track embed view events once the frame is shown (RND-12362) (#4517)

This commit is contained in:
Nolann B.
2026-08-20 14:04:14 +02:00
committed by GitHub
parent b13fd91afc
commit 8a700222e6
8 changed files with 129 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Only track embed view events once the frame is actually shown to the reader
+49 -1
View File
@@ -1,4 +1,4 @@
import { expect } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
@@ -52,6 +52,27 @@ const AI_PROMPT = [
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
// `InsightsProvider` debounces its flushes by 1.5s.
const INSIGHTS_FLUSH_TIMEOUT = 3000;
/**
* Collect the insights events of a given type sent by the page and its frames.
*/
function trackInsightsEvents(page: Page, type: string) {
const collected: { type: string }[] = [];
page.on('request', (request) => {
if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) {
return;
}
const body = request.postDataJSON() as { events?: { type: string }[] } | null;
collected.push(...(body?.events ?? []).filter((event) => event.type === type));
});
return collected;
}
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
if (greeting) {
@@ -2261,6 +2282,33 @@ const testCases: TestsCase[] = [
);
},
},
{
name: 'Only tracks ask_view once the widget is opened',
// `trigger=custom` loads the frame but leaves the window closed.
url: '?trigger=custom',
screenshot: false,
run: async (page) => {
const askViews = trackInsightsEvents(page, 'ask_view');
const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat');
// The assistant renders inside the hidden frame, but nobody has seen it.
await expect(chat).toBeAttached({ timeout: 20000 });
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(0);
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1);
// Hiding and showing the same frame again is not a second view.
await page.getByRole('button', { name: 'Close' }).click();
await expect(chat).toBeHidden();
await page.getByRole('button', { name: 'Open' }).click();
await expect(chat).toBeVisible();
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
expect(askViews).toHaveLength(1);
},
},
],
},
{
@@ -6,6 +6,7 @@ import * as api from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
EmbeddableFrame,
EmbeddableFrameBody,
@@ -51,9 +52,14 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
chatController.open();
}, [chatController]);
// Track the view of the AI chat
// Track the view of the AI chat, once the reader is actually shown the frame
const trackEvent = useTrackEvent();
const isVisible = useIsVisible();
React.useEffect(() => {
if (!isVisible) {
return;
}
trackEvent(
{
type: 'ask_view',
@@ -63,7 +69,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
displayContext: api.SiteInsightsDisplayContext.Embed,
}
);
}, [trackEvent]);
}, [trackEvent, isVisible]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const trademark = siteConfig.trademark;
@@ -4,6 +4,7 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
import { VisibilityProvider } from '../VisibilityContext';
import { EmbeddableAIContextProvider } from './EmbeddableAIContextProvider';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
import { EmbeddableThemeSync } from './EmbeddableThemeSync';
@@ -74,7 +75,7 @@ export async function EmbeddableRootLayout({
}}
>
<NavigationLoader />
<div className="fixed inset-0 flex flex-col">
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
<IfEmbeddableTrademark>
@@ -85,7 +86,7 @@ export async function EmbeddableRootLayout({
/>
</IfEmbeddableTrademark>
) : null}
</div>
</VisibilityProvider>
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
/>
@@ -4,6 +4,7 @@ import React from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
@@ -30,11 +31,16 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
const { hasDocsTab, linkContext } = useEmbeddableLinkContext();
const trackEvent = useTrackEvent();
const isVisible = useIsVisible();
React.useEffect(() => {
if (!isVisible) {
return;
}
trackEvent({
type: 'search_open',
});
}, [trackEvent]);
}, [trackEvent, isVisible]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const {
@@ -5,6 +5,7 @@ import * as React from 'react';
import type { SiteInsightsDisplayContext } from '@gitbook/api';
import { useCurrentPage } from '../hooks';
import { useIsVisible } from '../VisibilityContext';
import { useTrackEvent } from './InsightsProvider';
/**
@@ -14,8 +15,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
const { displayContext } = props;
const page = useCurrentPage();
const trackEvent = useTrackEvent();
// Always true outside of the embed, whose frame can be loaded while hidden.
const isVisible = useIsVisible();
React.useEffect(() => {
if (!isVisible) {
return;
}
trackEvent(
{
type: 'page_view',
@@ -25,7 +32,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
displayContext,
}
);
}, [page, trackEvent, displayContext]);
}, [page, trackEvent, displayContext, isVisible]);
return null;
}
@@ -0,0 +1,48 @@
'use client';
import React from 'react';
import { useInViewportListener } from '../hooks/useInViewportListener';
// Dwell before the content counts as seen. In the embed it also absorbs the initial
// `/assistant` render on frames configured without that tab, as `configure` arrives later.
const VISIBLE_DELAY_MS = 500;
// Without a provider the content is always considered visible.
const VisibilityContext = React.createContext(true);
// An iframe can be loaded while its host keeps it hidden, and a non-rendered iframe has a
// zero-sized viewport, which keeps the observer below non-intersecting until it is shown.
export function VisibilityProvider(props: { className: string; children: React.ReactNode }) {
const { className, children } = props;
const ref = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [inViewport, setInViewport] = React.useState(false);
useInViewportListener(ref, (isIntersecting) => setInViewport(isIntersecting));
// Latched: an observer inside an iframe also reports the host page scrolling it out of
// view, and scrolling past an inline embed is not a new view. Remounting is.
React.useEffect(() => {
if (!inViewport || visible) {
return;
}
const timeout = setTimeout(() => setVisible(true), VISIBLE_DELAY_MS);
return () => clearTimeout(timeout);
}, [inViewport, visible]);
return (
<VisibilityContext value={visible}>
<div ref={ref} className={className}>
{children}
</div>
</VisibilityContext>
);
}
// Always true outside of a `VisibilityProvider`.
export function useIsVisible() {
return React.use(VisibilityContext);
}
@@ -0,0 +1 @@
export * from './VisibilityContext';