Skip to content

Commit c71d159

Browse files
authored
Insights site events support (#2655)
1 parent 5950657 commit c71d159

File tree

23 files changed

+484
-141
lines changed

23 files changed

+484
-141
lines changed

.changeset/long-pumpkins-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': minor
3+
---
4+
5+
Track events for site insights using the new dedicated API.

bun.lockb

776 Bytes
Binary file not shown.

packages/gitbook/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
1717
},
1818
"dependencies": {
19-
"@gitbook/api": "^0.83.0",
19+
"@gitbook/api": "^0.84.0",
2020
"@gitbook/cache-do": "workspace:*",
2121
"@gitbook/emoji-codepoints": "workspace:*",
2222
"@gitbook/icons": "workspace:*",
@@ -64,7 +64,8 @@
6464
"tailwind-merge": "^2.2.0",
6565
"tailwind-shades": "^1.1.2",
6666
"unified": "^11.0.4",
67-
"url-join": "^5.0.0"
67+
"url-join": "^5.0.0",
68+
"usehooks-ts": "^3.1.0"
6869
},
6970
"devDependencies": {
7071
"@argos-ci/playwright": "^2.0.0",

packages/gitbook/src/app/(site)/(content)/[[...pathname]]/not-found.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import { TrackPageViewEvent } from '@/components/Insights';
12
import { getSpaceLanguage, t } from '@/intl/server';
2-
import { getSiteData } from '@/lib/api';
3+
import { getSiteData, getSpaceContentData } from '@/lib/api';
34
import { getSiteContentPointer } from '@/lib/pointer';
45
import { tcls } from '@/lib/tailwind';
56

67
export default async function NotFound() {
78
const pointer = getSiteContentPointer();
8-
const { customization } = await getSiteData(pointer);
9+
const [{ space }, { customization }] = await Promise.all([
10+
getSpaceContentData(pointer, pointer.siteShareKey),
11+
getSiteData(pointer),
12+
]);
913

1014
const language = getSpaceLanguage(customization);
1115

@@ -19,6 +23,9 @@ export default async function NotFound() {
1923
</h2>
2024
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
2125
</div>
26+
27+
{/* Track the page not found as a page view */}
28+
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
2229
</div>
2330
);
2431
}

packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
99
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
1010
import { ContentRefContext } from '@/lib/references';
1111
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
12-
import { tcls } from '@/lib/tailwind';
1312
import { getContentTitle } from '@/lib/utils';
1413

1514
import { PageClientLayout } from './PageClientLayout';

packages/gitbook/src/components/Cookies/CookiesToast.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import * as React from 'react';
66
import { Button } from '@/components/primitives';
77
import { useLanguage } from '@/intl/client';
88
import { t, tString } from '@/intl/translate';
9-
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
109
import { tcls } from '@/lib/tailwind';
1110

11+
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
12+
1213
/**
1314
* Toast to accept or reject the use of cookies.
1415
*/
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
'use client';
2+
3+
import type * as api from '@gitbook/api';
4+
import cookies from 'js-cookie';
5+
import * as React from 'react';
6+
import { useEventCallback, useDebounceCallback } from 'usehooks-ts';
7+
8+
import { getSession } from './sessions';
9+
import { getVisitorId } from './visitorId';
10+
11+
interface InsightsEventContext {
12+
organizationId: string;
13+
siteId: string;
14+
siteSectionId: string | undefined;
15+
siteSpaceId: string | undefined;
16+
spaceId: string;
17+
siteShareKey: string | undefined;
18+
}
19+
20+
interface InsightsEventPageContext {
21+
pageId: string | null;
22+
revisionId: string;
23+
}
24+
25+
type SiteEventName = api.SiteInsightsEvent['type'];
26+
27+
type TrackEventInput<EventName extends SiteEventName> = { type: EventName } & Omit<
28+
Extract<api.SiteInsightsEvent, { type: EventName }>,
29+
'location' | 'session'
30+
>;
31+
32+
type TrackEventCallback = <EventName extends SiteEventName>(
33+
event: TrackEventInput<EventName>,
34+
ctx?: InsightsEventPageContext,
35+
) => void;
36+
37+
const InsightsContext = React.createContext<TrackEventCallback | null>(null);
38+
39+
interface InsightsProviderProps extends InsightsEventContext {
40+
enabled: boolean;
41+
apiHost: string;
42+
children: React.ReactNode;
43+
}
44+
45+
/**
46+
* Wrap the content of the app with the InsightsProvider to track events.
47+
*/
48+
export function InsightsProvider(props: InsightsProviderProps) {
49+
const { enabled, apiHost, children, ...context } = props;
50+
51+
const eventsRef = React.useRef<{
52+
[pathname: string]:
53+
| {
54+
url: string;
55+
events: TrackEventInput<SiteEventName>[];
56+
context: InsightsEventContext;
57+
pageContext?: InsightsEventPageContext;
58+
}
59+
| undefined;
60+
}>({});
61+
62+
const flushEvents = useDebounceCallback(async (pathname: string) => {
63+
const visitorId = await getVisitorId();
64+
const session = await getSession();
65+
66+
const eventsForPathname = eventsRef.current[pathname];
67+
if (!eventsForPathname || !eventsForPathname.pageContext) {
68+
console.warn('No events to flush', eventsForPathname);
69+
return;
70+
}
71+
72+
const events = transformEvents({
73+
url: eventsForPathname.url,
74+
events: eventsForPathname.events,
75+
context,
76+
pageContext: eventsForPathname.pageContext,
77+
visitorId,
78+
sessionId: session.id,
79+
});
80+
81+
// Reset the events for the next flush
82+
eventsRef.current[pathname] = {
83+
...eventsForPathname,
84+
events: [],
85+
};
86+
87+
if (enabled) {
88+
console.log('Sending events', events);
89+
await sendEvents({
90+
apiHost,
91+
organizationId: context.organizationId,
92+
siteId: context.siteId,
93+
events,
94+
});
95+
} else {
96+
console.log('Events not sent', events);
97+
}
98+
}, 500);
99+
100+
const trackEvent = useEventCallback(
101+
(event: TrackEventInput<SiteEventName>, ctx?: InsightsEventPageContext) => {
102+
console.log('Logging event', event, ctx);
103+
104+
const pathname = window.location.pathname;
105+
const previous = eventsRef.current[pathname];
106+
eventsRef.current[pathname] = {
107+
pageContext: previous?.pageContext ?? ctx,
108+
url: previous?.url ?? window.location.href,
109+
events: [...(previous?.events ?? []), event],
110+
context,
111+
};
112+
113+
if (eventsRef.current[pathname].pageContext !== undefined) {
114+
// If the pageId is set, we know that the page_view event has been tracked
115+
// and we can flush the events
116+
flushEvents(pathname);
117+
}
118+
},
119+
);
120+
121+
return <InsightsContext.Provider value={trackEvent}>{props.children}</InsightsContext.Provider>;
122+
}
123+
124+
/**
125+
* Get a callback to track an event.
126+
*/
127+
export function useTrackEvent(): TrackEventCallback {
128+
const trackEvent = React.useContext(InsightsContext);
129+
if (!trackEvent) {
130+
throw new Error('useTrackEvent must be used within an InsightsProvider');
131+
}
132+
133+
return trackEvent;
134+
}
135+
136+
/**
137+
* Post the events to the server.
138+
*/
139+
async function sendEvents(args: {
140+
apiHost: string;
141+
organizationId: string;
142+
siteId: string;
143+
events: api.SiteInsightsEvent[];
144+
}) {
145+
const { apiHost, organizationId, siteId, events } = args;
146+
const url = new URL(apiHost);
147+
url.pathname = `/v1/orgs/${organizationId}/sites/${siteId}/insights/events`;
148+
149+
await fetch(url, {
150+
method: 'POST',
151+
headers: {
152+
'Content-Type': 'application/json',
153+
},
154+
body: JSON.stringify({
155+
events,
156+
}),
157+
});
158+
}
159+
160+
/**
161+
* Transform the events to the format expected by the API.
162+
*/
163+
function transformEvents(input: {
164+
url: string;
165+
events: TrackEventInput<SiteEventName>[];
166+
context: InsightsEventContext;
167+
pageContext: InsightsEventPageContext;
168+
visitorId: string;
169+
sessionId: string;
170+
}): api.SiteInsightsEvent[] {
171+
const session: api.SiteInsightsEventSession = {
172+
sessionId: input.sessionId,
173+
visitorId: input.visitorId,
174+
userAgent: window.navigator.userAgent,
175+
language: window.navigator.language,
176+
cookies: cookies.get(),
177+
referrer: document.referrer,
178+
};
179+
180+
const location: api.SiteInsightsEventLocation = {
181+
url: input.url,
182+
siteSection: input.context.siteSectionId ?? null,
183+
siteSpace: input.context.siteSpaceId ?? null,
184+
space: input.context.spaceId,
185+
siteShareKey: input.context.siteShareKey ?? null,
186+
page: input.pageContext.pageId,
187+
revision: input.pageContext.revisionId,
188+
};
189+
190+
return input.events.map((partialEvent) => {
191+
// @ts-expect-error: Partial event
192+
const event: api.SiteInsightsEvent = {
193+
...partialEvent,
194+
session,
195+
location,
196+
};
197+
198+
return event;
199+
});
200+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
import { useTrackEvent } from './InsightsProvider';
6+
7+
/**
8+
* Track a page view event.
9+
*/
10+
export function TrackPageViewEvent(props: { pageId: string | null; revisionId: string }) {
11+
const { pageId, revisionId } = props;
12+
const trackEvent = useTrackEvent();
13+
14+
React.useEffect(() => {
15+
trackEvent(
16+
{
17+
type: 'page_view',
18+
},
19+
{
20+
pageId,
21+
revisionId,
22+
},
23+
);
24+
}, [pageId, revisionId, trackEvent]);
25+
26+
return null;
27+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
3+
import cookies from 'js-cookie';
4+
5+
const GRANTED_COOKIE = '__gitbook_cookie_granted';
6+
7+
/**
8+
* Accept or reject cookies.
9+
*/
10+
export function setCookiesTracking(enabled: boolean) {
11+
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
12+
expires: 365,
13+
sameSite: 'none',
14+
secure: true,
15+
});
16+
}
17+
18+
/**
19+
* Return true if cookies are accepted or not.
20+
* Return `undefined` if state is not known.
21+
*/
22+
export function isCookiesTrackingDisabled() {
23+
const state = cookies.get(GRANTED_COOKIE);
24+
25+
if (state === 'yes') {
26+
return false;
27+
} else if (state === 'no') {
28+
return true;
29+
}
30+
31+
return undefined;
32+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './InsightsProvider';
2+
export * from './visitorId';
3+
export * from './cookies';
4+
export * from './TrackPageViewEvent';

0 commit comments

Comments
 (0)