mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 06:09:21 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 295a7fab34 | |||
| 3a23005020 | |||
| 0b64e40d9f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Expose a hook useVisitorSession to access the current user/visitor
|
||||
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
import { getAllBrowserCookiesMap } from '@/lib/browser';
|
||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
import { type SessionResponse, useVisitorSession } from './visitorId';
|
||||
|
||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||
|
||||
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
|
||||
/** If true, the events will be sent to the server. */
|
||||
enabled: boolean;
|
||||
|
||||
/** If true, the visitor cookie tracking will be used */
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
|
||||
/** The application URL. */
|
||||
appURL: string;
|
||||
|
||||
/** The url of the endpoint to send events to */
|
||||
eventUrl: string;
|
||||
|
||||
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
|
||||
* Wrap the content of the app with the InsightsProvider to track events.
|
||||
*/
|
||||
export function InsightsProvider(props: InsightsProviderProps) {
|
||||
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
|
||||
const { enabled, children, eventUrl } = props;
|
||||
|
||||
const visitorSession = useVisitorSession();
|
||||
const currentContent = useCurrentContent();
|
||||
const visitorIdRef = React.useRef<string | null>(null);
|
||||
const eventsRef = React.useRef<{
|
||||
[pathname: string]:
|
||||
| {
|
||||
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
*/
|
||||
const flushEventsSync = useEventCallback(() => {
|
||||
const session = getSession();
|
||||
const visitorId = visitorIdRef.current;
|
||||
if (!visitorId) {
|
||||
throw new Error('Visitor ID should be set before flushing events');
|
||||
if (!visitorSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allEvents: api.SiteInsightsEvent[] = [];
|
||||
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
events: eventsForPathname.events,
|
||||
context: currentContent,
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
visitorId,
|
||||
visitorSession,
|
||||
sessionId: session.id,
|
||||
})
|
||||
);
|
||||
@@ -137,14 +130,17 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
}
|
||||
});
|
||||
|
||||
const flushBatchedEvents = useDebounceCallback(async () => {
|
||||
const visitorId =
|
||||
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
|
||||
visitorIdRef.current = visitorId;
|
||||
|
||||
const flushBatchedEvents = useDebounceCallback(() => {
|
||||
flushEventsSync();
|
||||
}, 1500);
|
||||
|
||||
// Flush events once the visitor session is set
|
||||
React.useEffect(() => {
|
||||
if (visitorSession) {
|
||||
flushEventsSync();
|
||||
}
|
||||
}, [visitorSession, flushEventsSync]);
|
||||
|
||||
const trackEvent: TrackEventCallback = useEventCallback(
|
||||
(
|
||||
event: TrackEventInput<InsightsEventName>,
|
||||
@@ -169,7 +165,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
if (eventsRef.current[pathname].pageContext !== undefined) {
|
||||
// If the pageId is set, we know that the page_view event has been tracked
|
||||
// and we can flush the events
|
||||
if (options?.immediate && visitorIdRef.current) {
|
||||
if (options?.immediate) {
|
||||
flushBatchedEvents.cancel();
|
||||
flushEventsSync();
|
||||
} else {
|
||||
@@ -180,18 +176,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the visitor ID and store it in a ref.
|
||||
* When the page is unloaded, flush all events.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
|
||||
visitorIdRef.current = visitorId;
|
||||
// When the page is unloaded, flush all events, but only if the visitor ID is set
|
||||
window.addEventListener('beforeunload', flushEventsSync);
|
||||
});
|
||||
window.addEventListener('beforeunload', flushEventsSync);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushEventsSync);
|
||||
};
|
||||
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
|
||||
}, [flushEventsSync]);
|
||||
|
||||
return (
|
||||
<InsightsContext.Provider value={trackEvent}>
|
||||
@@ -245,12 +237,12 @@ function transformEvents(input: {
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: CurrentContentContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
visitorSession: SessionResponse;
|
||||
sessionId: string;
|
||||
}): api.SiteInsightsEvent[] {
|
||||
const session: api.SiteInsightsEventSession = {
|
||||
sessionId: input.sessionId,
|
||||
visitorId: input.visitorId,
|
||||
visitorId: input.visitorSession.deviceId,
|
||||
userAgent: window.navigator.userAgent,
|
||||
language: window.navigator.language,
|
||||
cookies: getAllBrowserCookiesMap(),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getBrowserCookie } from '@/lib/browser';
|
||||
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
|
||||
let visitorId: string | null = null;
|
||||
let pendingVisitorId: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Return the current visitor identifier.
|
||||
*/
|
||||
export async function getVisitorId(
|
||||
appURL: string,
|
||||
visitorCookieTrackingEnabled: boolean
|
||||
): Promise<string> {
|
||||
if (!visitorId) {
|
||||
if (!pendingVisitorId) {
|
||||
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
|
||||
pendingVisitorId = null;
|
||||
});
|
||||
}
|
||||
|
||||
visitorId = await pendingVisitorId;
|
||||
}
|
||||
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
||||
*/
|
||||
async function fetchVisitorID(
|
||||
appURL: string,
|
||||
visitorCookieTrackingEnabled: boolean
|
||||
): Promise<string> {
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
return generateRandomId();
|
||||
}
|
||||
|
||||
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existingTrackingCookie;
|
||||
}
|
||||
// No tracking deviceId set, we'll need to consolidate with the server.
|
||||
const proposed = generateRandomId();
|
||||
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposed);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET', // Use GET to play nicely with SameSite cookies.
|
||||
credentials: 'include', // Make sure to send/receive cookies.
|
||||
cache: 'no-cache',
|
||||
mode: 'cors', // Need to use cors as we are on a different domain.
|
||||
});
|
||||
|
||||
const { deviceId } = (await resp.json()) as { deviceId: string };
|
||||
return deviceId;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
return proposed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import { getBrowserCookie } from '@/lib/browser';
|
||||
|
||||
import React from 'react';
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
|
||||
type SessionVisitorResponse = {
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
type SessionUserResponse = SessionVisitorResponse & {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
|
||||
|
||||
const visitorSessionStore = createStore<{
|
||||
session: SessionResponse | null;
|
||||
pendingSession: Promise<SessionResponse> | null;
|
||||
}>(() => ({
|
||||
session: null,
|
||||
pendingSession: null,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Fetch and provide the visitor session.
|
||||
*/
|
||||
export function VisitorSessionProvider(
|
||||
props: React.PropsWithChildren<{
|
||||
appURL: string;
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
}>
|
||||
) {
|
||||
const { appURL, visitorCookieTrackingEnabled, children } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
const state = visitorSessionStore.getState();
|
||||
if (state.pendingSession || state.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
|
||||
visitorSessionStore.setState({ pendingSession, session: null });
|
||||
pendingSession.then((session) => {
|
||||
visitorSessionStore.setState({ pendingSession: null, session });
|
||||
});
|
||||
}, [appURL, visitorCookieTrackingEnabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current visitor session.
|
||||
*/
|
||||
export function useVisitorSession() {
|
||||
return useStore(visitorSessionStore, (state) => state.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
||||
*/
|
||||
async function fetchSession({
|
||||
appURL,
|
||||
visitorCookieTrackingEnabled,
|
||||
}: {
|
||||
appURL: string;
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
}): Promise<SessionResponse> {
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
return {
|
||||
deviceId: generateRandomId(),
|
||||
};
|
||||
}
|
||||
|
||||
const existingTrackingCookie = getSessionCookie();
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existingTrackingCookie;
|
||||
}
|
||||
// No tracking deviceId set, we'll need to consolidate with the server.
|
||||
const proposed = generateRandomId();
|
||||
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposed);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET', // Use GET to play nicely with SameSite cookies.
|
||||
credentials: 'include', // Make sure to send/receive cookies.
|
||||
cache: 'no-cache',
|
||||
mode: 'cors', // Need to use cors as we are on a different domain.
|
||||
});
|
||||
|
||||
const session = (await resp.json()) as SessionResponse;
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
return {
|
||||
deviceId: proposed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionCookie(): SessionResponse | null {
|
||||
const value = getBrowserCookie(VISITORID_COOKIE);
|
||||
if (!(value && typeof value === 'string')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as SessionResponse;
|
||||
return parsed;
|
||||
} catch {
|
||||
return { deviceId: value };
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { AIChat } from '../AIChat';
|
||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
import { InsightsProvider, VisitorSessionProvider } from '../Insights';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
revisionId={context.revisionId}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
<InsightsProvider
|
||||
enabled={withTracking}
|
||||
<VisitorSessionProvider
|
||||
appURL={GITBOOK_APP_URL}
|
||||
eventUrl={eventUrl.toString()}
|
||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||
>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
</VisitorSessionProvider>
|
||||
</CurrentContentProvider>
|
||||
</AdaptiveVisitorContextProvider>
|
||||
</SpaceLayoutContextProvider>
|
||||
|
||||
Reference in New Issue
Block a user