Add server-side insight event tracking for MCP route (#4063)

This commit is contained in:
Nolann B.
2026-02-27 17:16:21 +01:00
committed by GitHub
parent 1e9ed753a1
commit afa476c51f
4 changed files with 127 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add server-side insight event tracking for MCP route
@@ -1,7 +1,10 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
@@ -31,6 +34,22 @@ async function handler(
})
);
// Track the search event server-side
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request: nextRequest,
});
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
@@ -49,14 +49,12 @@ describe('getDocumentSections', () => {
nodes: [
{
object: 'block',
// @ts-expect-error columns is missing from top-level blocks, fixed in the next API update
type: 'columns',
data: {},
isVoid: false,
nodes: [
{
object: 'block',
// @ts-expect-error columns is missing from top-level blocks, fixed in the next API update
type: 'column',
data: {},
nodes: [
+103 -26
View File
@@ -1,6 +1,7 @@
import type * as api from '@gitbook/api';
import type { headers as nextHeaders } from 'next/headers';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_DISABLE_TRACKING } from './env';
import { apiClient } from './data/api';
import { GITBOOK_DISABLE_TRACKING } from './env';
/**
* Return true if events should be tracked on the site.
@@ -23,23 +24,87 @@ export function shouldTrackEvents(headers?: Awaited<ReturnType<typeof nextHeader
return true;
}
/**
* A server-side insight event input where session/location are optional.
* Missing fields are filled with sensible defaults.
*/
export type ServerInsightsEventInput = api.SiteInsightsEvent extends infer E
? E extends api.SiteInsightsEventBase
? Omit<E, 'session' | 'location' | 'timestamp'> & {
session?: Partial<api.SiteInsightsEventSession>;
location?: Partial<api.SiteInsightsEventLocation>;
timestamp?: string;
}
: never
: never;
const defaultLocation: api.SiteInsightsEventLocation = {
url: '',
siteSection: null,
siteSpace: null,
siteShareKey: null,
space: null,
revision: null,
page: null,
};
/**
* Extract a full session object from a request.
* Generates new sessionId/visitorId and extracts headers.
*/
function extractSessionFromRequest(request: Request): api.SiteInsightsEventSession {
return {
sessionId: crypto.randomUUID(),
visitorId: crypto.randomUUID(),
userAgent: request.headers.get('user-agent') ?? '',
language: request.headers.get('accept-language')?.split(',')[0] ?? null,
cookies: {},
referrer: request.headers.get('referer') ?? null,
};
}
/**
* Track insight events server-side via the GitBook API.
* Session info (userAgent, IDs) and location URL are automatically extracted from the request.
* Event-level overrides take precedence.
*/
export async function trackServerInsightsEvents(args: {
organizationId: string;
siteId: string;
events: ServerInsightsEventInput[];
request: Request;
}) {
if (GITBOOK_DISABLE_TRACKING) {
return;
}
const { organizationId, siteId, events, request } = args;
const api = apiClient();
const geolocation = extractGeolocation(request);
const requestSession = extractSessionFromRequest(request);
const fullEvents: api.SiteInsightsEvent[] = events.map((event) => ({
...event,
session: { ...requestSession, ...event.session },
location: { ...defaultLocation, url: request.url, ...event.location },
timestamp: event.timestamp ?? new Date().toISOString(),
})) as api.SiteInsightsEvent[];
return await api.orgs.trackEventsInSiteById(
organizationId,
siteId,
{ events: fullEvents },
{ headers: geolocation }
);
}
/**
* Serve as a proxy to the analytics endpoint, forwarding the request body and required parameters.
*/
export async function serveProxyAnalyticsEvent(req: Request) {
const requestURL = new URL(req.url);
// Fill geolocation data from request headers either from OpenNext or Vercel
const country =
req.headers.get('x-open-next-country') || req.headers.get('x-vercel-ip-country');
const latitude =
req.headers.get('x-open-next-latitude') || req.headers.get('x-vercel-ip-latitude');
const longitude =
req.headers.get('x-open-next-longitude') || req.headers.get('x-vercel-ip-longitude');
// OpenNext doesn't provide continent info, we add it manually in our custom worker
const continent =
req.headers.get('x-open-next-continent') || req.headers.get('x-vercel-ip-continent');
const org = requestURL.searchParams.get('o');
const site = requestURL.searchParams.get('s');
if (!org || !site) {
@@ -65,19 +130,31 @@ export async function serveProxyAnalyticsEvent(req: Request) {
});
}
// We make the request to the public API URL to ensure the request is properly enriched by the router..
const url = new URL(`${GITBOOK_API_PUBLIC_URL}/v1/orgs/${org}/sites/${site}/insights/events`);
return await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(country ? { 'x-location-country': country } : {}),
...(latitude ? { 'x-location-latitude': latitude } : {}),
...(longitude ? { 'x-location-longitude': longitude } : {}),
...(continent ? { 'x-location-continent': continent } : {}),
},
body: JSON.stringify({
events: filteredEvents,
}),
return await trackServerInsightsEvents({
organizationId: org,
siteId: site,
events: filteredEvents,
request: req,
});
}
/**
* Extract geolocation headers from a request (Vercel/OpenNext).
*/
function extractGeolocation(req: Request): Record<string, string> {
const country =
req.headers.get('x-open-next-country') || req.headers.get('x-vercel-ip-country');
const latitude =
req.headers.get('x-open-next-latitude') || req.headers.get('x-vercel-ip-latitude');
const longitude =
req.headers.get('x-open-next-longitude') || req.headers.get('x-vercel-ip-longitude');
const continent =
req.headers.get('x-open-next-continent') || req.headers.get('x-vercel-ip-continent');
return {
...(country ? { 'x-location-country': country } : {}),
...(latitude ? { 'x-location-latitude': latitude } : {}),
...(longitude ? { 'x-location-longitude': longitude } : {}),
...(continent ? { 'x-location-continent': continent } : {}),
};
}