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
5 changes: 5 additions & 0 deletions .changeset/wet-cases-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Only track embed view events once the frame is actually shown to the reader
50 changes: 49 additions & 1 deletion packages/gitbook/e2e/internal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import jwt from 'jsonwebtoken';

import {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
},
},
],
},
{
Expand Down
10 changes: 8 additions & 2 deletions packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand All @@ -85,7 +86,7 @@ export async function EmbeddableRootLayout({
/>
</IfEmbeddableTrademark>
) : null}
</div>
</VisibilityProvider>
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';

import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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',
Expand All @@ -25,7 +32,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
displayContext,
}
);
}, [page, trackEvent, displayContext]);
}, [page, trackEvent, displayContext, isVisible]);

return null;
}
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions packages/gitbook/src/components/VisibilityContext/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './VisibilityContext';
Loading