mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 11:26:31 +00:00
Add server-side insight event tracking for MCP route (#4063)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add server-side insight event tracking for MCP route
|
||||
+19
@@ -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: [
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user