diff --git a/packages/docs/src/analytics.test.ts b/packages/docs/src/analytics.test.ts index df02f06c..427bc3d1 100644 --- a/packages/docs/src/analytics.test.ts +++ b/packages/docs/src/analytics.test.ts @@ -343,6 +343,182 @@ describe("analytics", () => { ); }); + it("adds agent traffic hints to Docs Cloud events from caller identity", async () => { + process.env.NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID = "project_agents"; + + const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>( + async () => new Response(null, { status: 202 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "agent_read", + source: "server", + path: "/docs/install.md", + properties: { + delivery: "markdown_route", + userAgent: "ChatGPT-User/1.0", + }, + }, + ); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "markdown_request", + source: "server", + path: "/docs/install.md", + properties: { + delivery: "md_route", + userAgent: "Mozilla/5.0", + }, + }, + ); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "skill_request", + source: "server", + path: "/skill.md", + properties: { + userAgent: "CodexTestBot/1.0", + }, + }, + ); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "llms_request", + source: "server", + path: "/llms.txt", + properties: { + trafficType: "agent", + }, + }, + ); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "agent_read", + source: "server", + path: "/docs/install.md", + properties: { + delivery: "markdown_route", + userAgent: "Mozilla/5.0", + }, + }, + ); + + await emitDocsAnalyticsEvent( + { + enabled: true, + console: false, + }, + { + type: "mcp_tool", + source: "mcp", + path: "/docs/install", + properties: { + tool: "read_page", + agentName: " ", + botProvider: " ", + }, + }, + ); + + const requestBodies = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body))); + expect(requestBodies[0]).toMatchObject({ + event: { + type: "agent_read", + source: "server", + properties: { + delivery: "markdown_route", + userAgent: "ChatGPT-User/1.0", + trafficType: "agent", + agentName: "ChatGPT", + botProvider: "ChatGPT", + }, + }, + }); + expect(requestBodies[1]).toMatchObject({ + event: { + type: "markdown_request", + source: "server", + properties: { + delivery: "md_route", + userAgent: "Mozilla/5.0", + }, + }, + }); + expect(requestBodies[1].event.properties).not.toHaveProperty("trafficType"); + expect(requestBodies[2]).toMatchObject({ + event: { + type: "skill_request", + source: "server", + properties: { + userAgent: "CodexTestBot/1.0", + trafficType: "agent", + agentName: "Codex", + botProvider: "Codex", + }, + }, + }); + expect(requestBodies[3]).toMatchObject({ + event: { + type: "llms_request", + source: "server", + properties: { + trafficType: "agent", + agentName: "Docs reader", + botProvider: "Docs reader", + }, + }, + }); + expect(requestBodies[4]).toMatchObject({ + event: { + type: "agent_read", + source: "server", + properties: { + delivery: "markdown_route", + userAgent: "Mozilla/5.0", + }, + }, + }); + expect(requestBodies[4].event.properties).not.toHaveProperty("trafficType"); + expect(requestBodies[5]).toMatchObject({ + event: { + type: "mcp_tool", + source: "mcp", + properties: { + tool: "read_page", + trafficType: "agent", + agentName: "MCP client", + botProvider: "MCP client", + }, + }, + }); + }); + it("posts Docs Cloud analytics from plain config when public env is present", async () => { process.env.NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID = "project_public"; process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT = diff --git a/packages/docs/src/analytics.ts b/packages/docs/src/analytics.ts index d107cab0..3a333fe8 100644 --- a/packages/docs/src/analytics.ts +++ b/packages/docs/src/analytics.ts @@ -116,6 +116,12 @@ export function createDocsAgentTraceContext(name = "agent.run"): DocsAgentTraceC }; } +export function getDocsRequestAnalyticsProperties(request: Request): Record { + const userAgent = request.headers.get("user-agent")?.trim(); + + return userAgent ? { userAgent } : {}; +} + export function resolveDocsAnalyticsConfig( analytics?: boolean | DocsAnalyticsConfig, ): ResolvedDocsAnalyticsConfig { diff --git a/packages/docs/src/cloud-analytics.ts b/packages/docs/src/cloud-analytics.ts index cee2a6b1..bd670ec6 100644 --- a/packages/docs/src/cloud-analytics.ts +++ b/packages/docs/src/cloud-analytics.ts @@ -87,6 +87,124 @@ export function resolveDocsCloudAnalyticsOptions( }; } +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeAnalyticsLabel(value: string | undefined) { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[-\s]+/g, "_") ?? "" + ); +} + +function isProtocolAgentEvent(event: DocsAnalyticsEvent) { + const type = normalizeAnalyticsLabel(event.type); + const source = normalizeAnalyticsLabel(event.source); + + return source === "mcp" || type.startsWith("mcp_"); +} + +function inferAgentProvider(event: DocsAnalyticsEvent) { + const type = normalizeAnalyticsLabel(event.type); + const source = normalizeAnalyticsLabel(event.source); + + if (source === "mcp" || type.startsWith("mcp_")) { + return "MCP client"; + } + + if (type.startsWith("agent_") || type === "agents_request") { + return "Docs agent"; + } + + if (["llms_request", "markdown_request", "skill_request"].includes(type)) { + return "Docs reader"; + } + + return undefined; +} + +function detectAgentProviderFromUserAgent(userAgent: string | undefined) { + const value = userAgent?.toLowerCase() ?? ""; + + if (!value) { + return undefined; + } + + const providers: Array<[RegExp, string]> = [ + [/cursor/i, "Cursor"], + [/codex/i, "Codex"], + [/chatgpt-user|chatgpt/i, "ChatGPT"], + [/gptbot/i, "GPTBot"], + [/oai-searchbot|openai-search/i, "ChatGPT Search"], + [/openai/i, "ChatGPT"], + [/github-copilot|githubcopilot|copilot/i, "GitHub Copilot"], + [/claudebot|claude-user|anthropic/i, "Claude"], + [/perplexitybot|perplexity-user/i, "Perplexity"], + [/google-extended|googlebot|apis-google/i, "Google"], + [/bingbot|msnbot/i, "Bing"], + [/duckduckbot/i, "DuckDuckGo"], + [/applebot/i, "Apple"], + [/bytespider|bytedance/i, "ByteDance"], + [/ccbot|common crawl/i, "Common Crawl"], + [/ahrefsbot/i, "Ahrefs"], + [/semrushbot/i, "Semrush"], + ]; + + for (const [pattern, provider] of providers) { + if (pattern.test(value)) { + return provider; + } + } + + if (/bot|crawler|spider|slurp|facebookexternalhit|ia_archiver/.test(value)) { + return "Other bot"; + } + + return undefined; +} + +function withDocsCloudAnalyticsHints(event: DocsAnalyticsEvent): DocsAnalyticsEvent { + const properties = asRecord(event.properties); + const userAgent = asString(properties.userAgent) ?? asString(properties.user_agent); + const detectedAgent = detectAgentProviderFromUserAgent(userAgent); + const protocolAgent = isProtocolAgentEvent(event); + const incomingTrafficType = asString(properties.trafficType)?.toLowerCase(); + const explicitAgent = incomingTrafficType === "agent" || incomingTrafficType === "bot"; + // Agent-readable routes can still be opened by humans, so event type alone is not identity. + const agentProvider = + asString(properties.agentName) ?? + asString(properties.agent) ?? + asString(properties.botProvider) ?? + asString(properties.provider) ?? + asString(properties.crawler) ?? + asString(asRecord(properties.bot).provider) ?? + detectedAgent ?? + (protocolAgent || explicitAgent ? inferAgentProvider(event) : undefined); + + if (!explicitAgent && !protocolAgent && !detectedAgent && !agentProvider) { + return event; + } + + return { + ...event, + properties: { + ...properties, + trafficType: "agent", + ...(agentProvider && !asString(properties.agentName) ? { agentName: agentProvider } : {}), + ...(agentProvider && !asString(properties.botProvider) ? { botProvider: agentProvider } : {}), + }, + }; +} + export async function sendDocsCloudAnalyticsEvent( options: DocsCloudAnalyticsOptions, event: DocsAnalyticsEvent, @@ -102,6 +220,7 @@ export async function sendDocsCloudAnalyticsEvent( } try { + const normalizedEvent = withDocsCloudAnalyticsHints(event); await fetch(endpoint, { method: "POST", headers: { @@ -114,7 +233,7 @@ export async function sendDocsCloudAnalyticsEvent( }, body: JSON.stringify({ projectId, - event, + event: normalizedEvent, }), keepalive: true, }); diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts index 396aa0da..ccd045ed 100644 --- a/packages/docs/src/index.ts +++ b/packages/docs/src/index.ts @@ -17,6 +17,7 @@ export { emitDocsAgentTraceEvent, emitDocsAnalyticsEvent, emitDocsObservabilityEvent, + getDocsRequestAnalyticsProperties, resolveDocsAnalyticsConfig, resolveDocsObservabilityConfig, } from "./analytics.js"; diff --git a/packages/docs/src/server.ts b/packages/docs/src/server.ts index b3a448f9..19b5b9c0 100644 --- a/packages/docs/src/server.ts +++ b/packages/docs/src/server.ts @@ -6,6 +6,7 @@ export { emitDocsAgentTraceEvent, emitDocsAnalyticsEvent, emitDocsObservabilityEvent, + getDocsRequestAnalyticsProperties, resolveDocsAnalyticsConfig, resolveDocsObservabilityConfig, } from "./analytics.js"; diff --git a/packages/fumadocs/src/docs-api.test.ts b/packages/fumadocs/src/docs-api.test.ts index 7c1c7ebf..bf859f31 100644 --- a/packages/fumadocs/src/docs-api.test.ts +++ b/packages/fumadocs/src/docs-api.test.ts @@ -1276,6 +1276,11 @@ Install the package. "accept_header", "user_agent", ]); + expect(agentReads.find((event) => event.properties?.delivery === "user_agent")).toMatchObject({ + properties: expect.objectContaining({ + userAgent: "ClaudeBot/1.0", + }), + }); expect(agentReads).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/fumadocs/src/docs-api.ts b/packages/fumadocs/src/docs-api.ts index 6652f232..9f72608c 100644 --- a/packages/fumadocs/src/docs-api.ts +++ b/packages/fumadocs/src/docs-api.ts @@ -2241,6 +2241,12 @@ function safeUrlOrigin(value: string): string { } } +function getRequestAnalyticsProperties(request: Request): Record { + const userAgent = request.headers.get("user-agent")?.trim(); + + return userAgent ? { userAgent } : {}; +} + async function handleAskAI( request: Request, indexes: DocsSearchSourcePage[], @@ -2251,6 +2257,7 @@ async function handleAskAI( analyticsContext: { locale?: string } = {}, ): Promise { const url = new URL(request.url); + const requestAnalyticsProperties = getRequestAnalyticsProperties(request); const requestStartedAt = Date.now(); const trace = createDocsAgentTraceContext("ask-ai"); const runSpanId = createDocsAgentTraceId("span"); @@ -2330,6 +2337,7 @@ async function handleAskAI( path: url.pathname, locale: analyticsContext.locale, properties: { + ...requestAnalyticsProperties, reason: "invalid_json", durationMs: Math.max(0, Date.now() - requestStartedAt), }, @@ -2350,6 +2358,7 @@ async function handleAskAI( path: url.pathname, locale: analyticsContext.locale, properties: { + ...requestAnalyticsProperties, reason: "missing_messages", durationMs: Math.max(0, Date.now() - requestStartedAt), }, @@ -2370,6 +2379,7 @@ async function handleAskAI( path: url.pathname, locale: analyticsContext.locale, properties: { + ...requestAnalyticsProperties, reason: "missing_user_message", messageCount: messages.length, durationMs: Math.max(0, Date.now() - requestStartedAt), @@ -2504,6 +2514,7 @@ async function handleAskAI( locale: analyticsContext.locale, input: { question: query }, properties: { + ...requestAnalyticsProperties, reason: "missing_api_key", messageCount: messages.length, questionLength: query.length, @@ -2535,6 +2546,7 @@ async function handleAskAI( locale: analyticsContext.locale, input: { question: query }, properties: { + ...requestAnalyticsProperties, messageCount: messages.length, questionLength: query.length, retrievedCount: scored.length, @@ -2604,6 +2616,7 @@ async function handleAskAI( locale: analyticsContext.locale, input: { question: query }, properties: { + ...requestAnalyticsProperties, reason: "llm_fetch_error", messageCount: messages.length, questionLength: query.length, @@ -2650,6 +2663,7 @@ async function handleAskAI( locale: analyticsContext.locale, input: { question: query }, properties: { + ...requestAnalyticsProperties, reason: "llm_error", status: llmResponse.status, messageCount: messages.length, @@ -2681,6 +2695,7 @@ async function handleAskAI( locale: analyticsContext.locale, input: { question: query }, properties: { + ...requestAnalyticsProperties, messageCount: messages.length, questionLength: query.length, retrievedCount: scored.length, @@ -3235,6 +3250,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { async GET(request: Request) { const ctx = resolveContextFromRequest(request); const url = new URL(request.url); + const requestAnalyticsProperties = getRequestAnalyticsProperties(request); if (resolveAgentSpecRequest(url)) { await emitDocsAnalyticsEvent(analytics, { type: "agent_spec_request", @@ -3243,6 +3259,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, method: "GET", }, }); @@ -3316,6 +3333,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, method: "GET", }, }); @@ -3336,6 +3354,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, method: "GET", }, }); @@ -3370,6 +3389,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, method: "GET", }, }); @@ -3451,6 +3471,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, requestedPath: markdownRequest.requestedPath, delivery: markdownRequest.delivery, found: false, @@ -3463,6 +3484,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, requestedPath: markdownRequest.requestedPath, delivery: markdownRequest.delivery, found: false, @@ -3505,6 +3527,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, requestedPath: markdownRequest.requestedPath, delivery: markdownRequest.delivery, found: true, @@ -3518,6 +3541,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, requestedPath: markdownRequest.requestedPath, delivery: markdownRequest.delivery, found: true, @@ -3583,6 +3607,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { path: url.pathname, locale: ctx.locale, properties: { + ...requestAnalyticsProperties, format: llmsRequest.format, section: llmsRequest.section?.route, contentLength: selected.content.length, @@ -3620,6 +3645,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { locale: ctx.locale, input: { query }, properties: { + ...requestAnalyticsProperties, queryLength: query.length, resultCount: results.length, pathname: url.searchParams.get("pathname") ?? undefined, @@ -3639,6 +3665,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { */ async POST(request: Request): Promise { const url = new URL(request.url); + const requestAnalyticsProperties = getRequestAnalyticsProperties(request); const agentFeedbackRequest = resolveAgentFeedbackRequest(url, agentFeedbackConfig); if (agentFeedbackRequest) { @@ -3662,6 +3689,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { url: request.url, path: url.pathname, properties: { + ...requestAnalyticsProperties, reason: "invalid_body", }, }); @@ -3679,6 +3707,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { url: request.url, path: url.pathname, properties: { + ...requestAnalyticsProperties, reason: "invalid_payload", error: payloadError, }, @@ -3693,6 +3722,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { url: request.url, path: url.pathname, properties: { + ...requestAnalyticsProperties, handled: false, payloadKeys: Object.keys(parsed.data.payload), hasContext: Boolean(parsed.data.context), @@ -3708,6 +3738,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { url: request.url, path: url.pathname, properties: { + ...requestAnalyticsProperties, handled: true, payloadKeys: Object.keys(parsed.data.payload), hasContext: Boolean(parsed.data.context), @@ -3723,6 +3754,7 @@ export function createDocsAPI(options?: DocsAPIOptions) { url: request.url, path: url.pathname, properties: { + ...requestAnalyticsProperties, reason: "disabled", }, });