mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +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 { getAllBrowserCookiesMap } from '@/lib/browser';
|
||||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||||
import { getSession } from './sessions';
|
import { getSession } from './sessions';
|
||||||
import { getVisitorId } from './visitorId';
|
import { type SessionResponse, useVisitorSession } from './visitorId';
|
||||||
|
|
||||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||||
|
|
||||||
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
|
|||||||
/** If true, the events will be sent to the server. */
|
/** If true, the events will be sent to the server. */
|
||||||
enabled: boolean;
|
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 */
|
/** The url of the endpoint to send events to */
|
||||||
eventUrl: string;
|
eventUrl: string;
|
||||||
|
|
||||||
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
|
|||||||
* Wrap the content of the app with the InsightsProvider to track events.
|
* Wrap the content of the app with the InsightsProvider to track events.
|
||||||
*/
|
*/
|
||||||
export function InsightsProvider(props: InsightsProviderProps) {
|
export function InsightsProvider(props: InsightsProviderProps) {
|
||||||
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
|
const { enabled, children, eventUrl } = props;
|
||||||
|
|
||||||
|
const visitorSession = useVisitorSession();
|
||||||
const currentContent = useCurrentContent();
|
const currentContent = useCurrentContent();
|
||||||
const visitorIdRef = React.useRef<string | null>(null);
|
|
||||||
const eventsRef = React.useRef<{
|
const eventsRef = React.useRef<{
|
||||||
[pathname: string]:
|
[pathname: string]:
|
||||||
| {
|
| {
|
||||||
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
*/
|
*/
|
||||||
const flushEventsSync = useEventCallback(() => {
|
const flushEventsSync = useEventCallback(() => {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const visitorId = visitorIdRef.current;
|
if (!visitorSession) {
|
||||||
if (!visitorId) {
|
return;
|
||||||
throw new Error('Visitor ID should be set before flushing events');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEvents: api.SiteInsightsEvent[] = [];
|
const allEvents: api.SiteInsightsEvent[] = [];
|
||||||
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
events: eventsForPathname.events,
|
events: eventsForPathname.events,
|
||||||
context: currentContent,
|
context: currentContent,
|
||||||
pageContext: eventsForPathname.pageContext,
|
pageContext: eventsForPathname.pageContext,
|
||||||
visitorId,
|
visitorSession,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -137,14 +130,17 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const flushBatchedEvents = useDebounceCallback(async () => {
|
const flushBatchedEvents = useDebounceCallback(() => {
|
||||||
const visitorId =
|
|
||||||
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
|
|
||||||
visitorIdRef.current = visitorId;
|
|
||||||
|
|
||||||
flushEventsSync();
|
flushEventsSync();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
|
// Flush events once the visitor session is set
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visitorSession) {
|
||||||
|
flushEventsSync();
|
||||||
|
}
|
||||||
|
}, [visitorSession, flushEventsSync]);
|
||||||
|
|
||||||
const trackEvent: TrackEventCallback = useEventCallback(
|
const trackEvent: TrackEventCallback = useEventCallback(
|
||||||
(
|
(
|
||||||
event: TrackEventInput<InsightsEventName>,
|
event: TrackEventInput<InsightsEventName>,
|
||||||
@@ -169,7 +165,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
if (eventsRef.current[pathname].pageContext !== undefined) {
|
if (eventsRef.current[pathname].pageContext !== undefined) {
|
||||||
// If the pageId is set, we know that the page_view event has been tracked
|
// If the pageId is set, we know that the page_view event has been tracked
|
||||||
// and we can flush the events
|
// and we can flush the events
|
||||||
if (options?.immediate && visitorIdRef.current) {
|
if (options?.immediate) {
|
||||||
flushBatchedEvents.cancel();
|
flushBatchedEvents.cancel();
|
||||||
flushEventsSync();
|
flushEventsSync();
|
||||||
} else {
|
} 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(() => {
|
React.useEffect(() => {
|
||||||
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
|
window.addEventListener('beforeunload', flushEventsSync);
|
||||||
visitorIdRef.current = visitorId;
|
|
||||||
// When the page is unloaded, flush all events, but only if the visitor ID is set
|
|
||||||
window.addEventListener('beforeunload', flushEventsSync);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', flushEventsSync);
|
window.removeEventListener('beforeunload', flushEventsSync);
|
||||||
};
|
};
|
||||||
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
|
}, [flushEventsSync]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InsightsContext.Provider value={trackEvent}>
|
<InsightsContext.Provider value={trackEvent}>
|
||||||
@@ -245,12 +237,12 @@ function transformEvents(input: {
|
|||||||
events: TrackEventInput<InsightsEventName>[];
|
events: TrackEventInput<InsightsEventName>[];
|
||||||
context: CurrentContentContext;
|
context: CurrentContentContext;
|
||||||
pageContext: InsightsEventPageContext;
|
pageContext: InsightsEventPageContext;
|
||||||
visitorId: string;
|
visitorSession: SessionResponse;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): api.SiteInsightsEvent[] {
|
}): api.SiteInsightsEvent[] {
|
||||||
const session: api.SiteInsightsEventSession = {
|
const session: api.SiteInsightsEventSession = {
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
visitorId: input.visitorId,
|
visitorId: input.visitorSession.deviceId,
|
||||||
userAgent: window.navigator.userAgent,
|
userAgent: window.navigator.userAgent,
|
||||||
language: window.navigator.language,
|
language: window.navigator.language,
|
||||||
cookies: getAllBrowserCookiesMap(),
|
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 { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider } from '../Insights';
|
import { InsightsProvider, VisitorSessionProvider } from '../Insights';
|
||||||
import { SearchContainer } from '../Search';
|
import { SearchContainer } from '../Search';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { CurrentContentProvider } from '../hooks';
|
import { CurrentContentProvider } from '../hooks';
|
||||||
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
|||||||
revisionId={context.revisionId}
|
revisionId={context.revisionId}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
>
|
||||||
<InsightsProvider
|
<VisitorSessionProvider
|
||||||
enabled={withTracking}
|
|
||||||
appURL={GITBOOK_APP_URL}
|
appURL={GITBOOK_APP_URL}
|
||||||
eventUrl={eventUrl.toString()}
|
|
||||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||||
>
|
>
|
||||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||||
{children}
|
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||||
</AIChatProvider>
|
{children}
|
||||||
</InsightsProvider>
|
</AIChatProvider>
|
||||||
|
</InsightsProvider>
|
||||||
|
</VisitorSessionProvider>
|
||||||
</CurrentContentProvider>
|
</CurrentContentProvider>
|
||||||
</AdaptiveVisitorContextProvider>
|
</AdaptiveVisitorContextProvider>
|
||||||
</SpaceLayoutContextProvider>
|
</SpaceLayoutContextProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user