Skip to content

Commit

Permalink
Insights site events support (#2655)
Browse files Browse the repository at this point in the history
  • Loading branch information
SamyPesse authored Dec 20, 2024
1 parent 5950657 commit c71d159
Show file tree
Hide file tree
Showing 23 changed files with 484 additions and 141 deletions.
5 changes: 5 additions & 0 deletions .changeset/long-pumpkins-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': minor
---

Track events for site insights using the new dedicated API.
Binary file modified bun.lockb
Binary file not shown.
5 changes: 3 additions & 2 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.83.0",
"@gitbook/api": "^0.84.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
Expand Down Expand Up @@ -64,7 +64,8 @@
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0"
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData } from '@/lib/api';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';

export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer);
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
]);

const language = getSpaceLanguage(customization);

Expand All @@ -19,6 +23,9 @@ export default async function NotFound() {
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>

{/* Track the page not found as a page view */}
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';

import { PageClientLayout } from './PageClientLayout';
Expand Down
3 changes: 2 additions & 1 deletion packages/gitbook/src/components/Cookies/CookiesToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import * as React from 'react';
import { Button } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
import { tcls } from '@/lib/tailwind';

import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';

/**
* Toast to accept or reject the use of cookies.
*/
Expand Down
200 changes: 200 additions & 0 deletions packages/gitbook/src/components/Insights/InsightsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
'use client';

import type * as api from '@gitbook/api';
import cookies from 'js-cookie';
import * as React from 'react';
import { useEventCallback, useDebounceCallback } from 'usehooks-ts';

import { getSession } from './sessions';
import { getVisitorId } from './visitorId';

interface InsightsEventContext {
organizationId: string;
siteId: string;
siteSectionId: string | undefined;
siteSpaceId: string | undefined;
spaceId: string;
siteShareKey: string | undefined;
}

interface InsightsEventPageContext {
pageId: string | null;
revisionId: string;
}

type SiteEventName = api.SiteInsightsEvent['type'];

type TrackEventInput<EventName extends SiteEventName> = { type: EventName } & Omit<
Extract<api.SiteInsightsEvent, { type: EventName }>,
'location' | 'session'
>;

type TrackEventCallback = <EventName extends SiteEventName>(
event: TrackEventInput<EventName>,
ctx?: InsightsEventPageContext,
) => void;

const InsightsContext = React.createContext<TrackEventCallback | null>(null);

interface InsightsProviderProps extends InsightsEventContext {
enabled: boolean;
apiHost: string;
children: React.ReactNode;
}

/**
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, apiHost, children, ...context } = props;

const eventsRef = React.useRef<{
[pathname: string]:
| {
url: string;
events: TrackEventInput<SiteEventName>[];
context: InsightsEventContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});

const flushEvents = useDebounceCallback(async (pathname: string) => {
const visitorId = await getVisitorId();
const session = await getSession();

const eventsForPathname = eventsRef.current[pathname];
if (!eventsForPathname || !eventsForPathname.pageContext) {
console.warn('No events to flush', eventsForPathname);
return;
}

const events = transformEvents({
url: eventsForPathname.url,
events: eventsForPathname.events,
context,
pageContext: eventsForPathname.pageContext,
visitorId,
sessionId: session.id,
});

// Reset the events for the next flush
eventsRef.current[pathname] = {
...eventsForPathname,
events: [],
};

if (enabled) {
console.log('Sending events', events);
await sendEvents({
apiHost,
organizationId: context.organizationId,
siteId: context.siteId,
events,
});
} else {
console.log('Events not sent', events);
}
}, 500);

const trackEvent = useEventCallback(
(event: TrackEventInput<SiteEventName>, ctx?: InsightsEventPageContext) => {
console.log('Logging event', event, ctx);

const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
eventsRef.current[pathname] = {
pageContext: previous?.pageContext ?? ctx,
url: previous?.url ?? window.location.href,
events: [...(previous?.events ?? []), event],
context,
};

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
flushEvents(pathname);
}
},
);

return <InsightsContext.Provider value={trackEvent}>{props.children}</InsightsContext.Provider>;
}

/**
* Get a callback to track an event.
*/
export function useTrackEvent(): TrackEventCallback {
const trackEvent = React.useContext(InsightsContext);
if (!trackEvent) {
throw new Error('useTrackEvent must be used within an InsightsProvider');
}

return trackEvent;
}

/**
* Post the events to the server.
*/
async function sendEvents(args: {
apiHost: string;
organizationId: string;
siteId: string;
events: api.SiteInsightsEvent[];
}) {
const { apiHost, organizationId, siteId, events } = args;
const url = new URL(apiHost);
url.pathname = `/v1/orgs/${organizationId}/sites/${siteId}/insights/events`;

await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
events,
}),
});
}

/**
* Transform the events to the format expected by the API.
*/
function transformEvents(input: {
url: string;
events: TrackEventInput<SiteEventName>[];
context: InsightsEventContext;
pageContext: InsightsEventPageContext;
visitorId: string;
sessionId: string;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: cookies.get(),
referrer: document.referrer,
};

const location: api.SiteInsightsEventLocation = {
url: input.url,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
siteShareKey: input.context.siteShareKey ?? null,
page: input.pageContext.pageId,
revision: input.pageContext.revisionId,
};

return input.events.map((partialEvent) => {
// @ts-expect-error: Partial event
const event: api.SiteInsightsEvent = {
...partialEvent,
session,
location,
};

return event;
});
}
27 changes: 27 additions & 0 deletions packages/gitbook/src/components/Insights/TrackPageViewEvent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import * as React from 'react';

import { useTrackEvent } from './InsightsProvider';

/**
* Track a page view event.
*/
export function TrackPageViewEvent(props: { pageId: string | null; revisionId: string }) {
const { pageId, revisionId } = props;
const trackEvent = useTrackEvent();

React.useEffect(() => {
trackEvent(
{
type: 'page_view',
},
{
pageId,
revisionId,
},
);
}, [pageId, revisionId, trackEvent]);

return null;
}
32 changes: 32 additions & 0 deletions packages/gitbook/src/components/Insights/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import cookies from 'js-cookie';

const GRANTED_COOKIE = '__gitbook_cookie_granted';

/**
* Accept or reject cookies.
*/
export function setCookiesTracking(enabled: boolean) {
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
expires: 365,
sameSite: 'none',
secure: true,
});
}

/**
* Return true if cookies are accepted or not.
* Return `undefined` if state is not known.
*/
export function isCookiesTrackingDisabled() {
const state = cookies.get(GRANTED_COOKIE);

if (state === 'yes') {
return false;
} else if (state === 'no') {
return true;
}

return undefined;
}
4 changes: 4 additions & 0 deletions packages/gitbook/src/components/Insights/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './InsightsProvider';
export * from './visitorId';
export * from './cookies';
export * from './TrackPageViewEvent';
Loading

0 comments on commit c71d159

Please sign in to comment.