mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 10:03:31 +00:00
Only track embed view events once the frame is shown (RND-12362) (#4517)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only track embed view events once the frame is actually shown to the reader
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user