diff --git a/.changeset/smart-bags-strive.md b/.changeset/smart-bags-strive.md new file mode 100644 index 000000000..c7ee0ad75 --- /dev/null +++ b/.changeset/smart-bags-strive.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add server-side insight event tracking for MCP route diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts index 7398b4095..5e48f69e6 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts @@ -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') { diff --git a/packages/gitbook/src/lib/document-sections.test.ts b/packages/gitbook/src/lib/document-sections.test.ts index 2049dc268..64e6bef56 100644 --- a/packages/gitbook/src/lib/document-sections.test.ts +++ b/packages/gitbook/src/lib/document-sections.test.ts @@ -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: [ diff --git a/packages/gitbook/src/lib/tracking.ts b/packages/gitbook/src/lib/tracking.ts index 810c1b2df..ac2ade36d 100644 --- a/packages/gitbook/src/lib/tracking.ts +++ b/packages/gitbook/src/lib/tracking.ts @@ -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 & { + session?: Partial; + location?: Partial; + 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 { + 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 } : {}), + }; +}