Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions packages/docs/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>>(
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 =
Expand Down
6 changes: 6 additions & 0 deletions packages/docs/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ export function createDocsAgentTraceContext(name = "agent.run"): DocsAgentTraceC
};
}

export function getDocsRequestAnalyticsProperties(request: Request): Record<string, unknown> {
const userAgent = request.headers.get("user-agent")?.trim();

return userAgent ? { userAgent } : {};
}

export function resolveDocsAnalyticsConfig(
analytics?: boolean | DocsAnalyticsConfig,
): ResolvedDocsAnalyticsConfig {
Expand Down
121 changes: 120 additions & 1 deletion packages/docs/src/cloud-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,124 @@ export function resolveDocsCloudAnalyticsOptions(
};
}

function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Agent-type events are no longer auto-classified unless trafficType is already set or the event is MCP, which regresses analytics tagging for existing agent_/llms_request/markdown_request/skill_request flows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/src/cloud-analytics.ts, line 190:

<comment>Agent-type events are no longer auto-classified unless `trafficType` is already set or the event is MCP, which regresses analytics tagging for existing `agent_`/`llms_request`/`markdown_request`/`skill_request` flows.</comment>

<file context>
@@ -137,20 +132,66 @@ function inferAgentProvider(event: DocsAnalyticsEvent) {
     asString(asRecord(properties.bot).provider) ??
-    inferAgentProvider(event);
+    detectedAgent ??
+    (protocolAgent || explicitAgent ? inferAgentProvider(event) : undefined);
+
+  if (!explicitAgent && !protocolAgent && !detectedAgent && !agentProvider) {
</file context>
Suggested change
(protocolAgent || explicitAgent ? inferAgentProvider(event) : undefined);
inferAgentProvider(event);


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,
Expand All @@ -102,6 +220,7 @@ export async function sendDocsCloudAnalyticsEvent(
}

try {
const normalizedEvent = withDocsCloudAnalyticsHints(event);
await fetch(endpoint, {
method: "POST",
headers: {
Expand All @@ -114,7 +233,7 @@ export async function sendDocsCloudAnalyticsEvent(
},
body: JSON.stringify({
projectId,
event,
event: normalizedEvent,
}),
keepalive: true,
});
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
emitDocsAgentTraceEvent,
emitDocsAnalyticsEvent,
emitDocsObservabilityEvent,
getDocsRequestAnalyticsProperties,
resolveDocsAnalyticsConfig,
resolveDocsObservabilityConfig,
} from "./analytics.js";
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
emitDocsAgentTraceEvent,
emitDocsAnalyticsEvent,
emitDocsObservabilityEvent,
getDocsRequestAnalyticsProperties,
resolveDocsAnalyticsConfig,
resolveDocsObservabilityConfig,
} from "./analytics.js";
Expand Down
5 changes: 5 additions & 0 deletions packages/fumadocs/src/docs-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading