diff --git a/packages/mcp-cloudflare/src/server/index.ts b/packages/mcp-cloudflare/src/server/index.ts index 0c7e91c3..84034ef0 100644 --- a/packages/mcp-cloudflare/src/server/index.ts +++ b/packages/mcp-cloudflare/src/server/index.ts @@ -3,6 +3,10 @@ import * as Sentry from "@sentry/cloudflare"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import { SCOPES } from "../constants"; import app from "./app"; +import { + UTM_SOURCE_ATTRIBUTE, + resolveUtmSourceFromUrl, +} from "./lib/attribution"; import { resolveClientFamily } from "./lib/client-family"; import { redirectUriHasUserInfo } from "./lib/html-utils"; import sentryMcpHandler from "./lib/mcp-handler"; @@ -189,7 +193,16 @@ const wrappedOAuthProvider = { } const clientFamily = resolveClientFamily(request.headers.get("user-agent")); - Sentry.getActiveSpan()?.setAttribute("app.client.family", clientFamily); + const activeSpan = Sentry.getActiveSpan(); + activeSpan?.setAttribute("app.client.family", clientFamily); + // Set utm_source early on /mcp requests so the attribute is present even + // if the request is rejected before reaching mcp-handler.ts. + if (url.pathname.startsWith("/mcp")) { + const utmSource = resolveUtmSourceFromUrl(url); + if (utmSource) { + activeSpan?.setAttribute(UTM_SOURCE_ATTRIBUTE, utmSource); + } + } // Reject registrations with userinfo-spoofed redirect URIs before the // library stores the client (e.g. host@example.io). diff --git a/packages/mcp-cloudflare/src/server/lib/attribution.test.ts b/packages/mcp-cloudflare/src/server/lib/attribution.test.ts new file mode 100644 index 00000000..7eb62ace --- /dev/null +++ b/packages/mcp-cloudflare/src/server/lib/attribution.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + UTM_SOURCE_ATTRIBUTE, + resolveUtmSource, + resolveUtmSourceFromUrl, +} from "./attribution"; + +describe("UTM_SOURCE_ATTRIBUTE", () => { + it("is app.utm_source", () => { + expect(UTM_SOURCE_ATTRIBUTE).toBe("app.utm_source"); + }); +}); + +describe("resolveUtmSource", () => { + it.each([ + // Known server-side values + ["plugin", "plugin"], + // Any other non-empty value buckets to "other" + ["unknown-source", "other"], + // Client-side values are intentionally not known server-side + ["sentry-mcp-settings-docs-btn", "other"], + // Absent / empty → null (do not set the attribute) + ["", null], + [null, null], + [undefined, null], + ])("maps %s → %s", (input, expected) => { + expect(resolveUtmSource(input)).toBe(expected); + }); +}); + +describe("resolveUtmSourceFromUrl", () => { + it("reads utm_source from URL search params", () => { + const url = new URL("https://mcp.sentry.dev/mcp?utm_source=plugin"); + expect(resolveUtmSourceFromUrl(url)).toBe("plugin"); + }); + + it("returns null when utm_source is absent", () => { + const url = new URL("https://mcp.sentry.dev/mcp"); + expect(resolveUtmSourceFromUrl(url)).toBeNull(); + }); + + it("buckets unknown values to other", () => { + const url = new URL("https://mcp.sentry.dev/mcp?utm_source=something-new"); + expect(resolveUtmSourceFromUrl(url)).toBe("other"); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/lib/attribution.ts b/packages/mcp-cloudflare/src/server/lib/attribution.ts new file mode 100644 index 00000000..fbb01a28 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/lib/attribution.ts @@ -0,0 +1,39 @@ +// Server-side attribution helpers. These live separately from client-side +// attribution (client/utils/attribution.ts) because the two contexts have +// different known utm_source values and different APIs (no window/document +// on the server). + +/** + * Span/metric attribute name for the bucketed utm_source value. + * Use this constant in all call sites to avoid drift. + */ +export const UTM_SOURCE_ATTRIBUTE = "app.utm_source"; + +/** + * Buckets a raw utm_source query param value into a fixed allow-list so it + * is safe to use as a metric/span attribute (raw values are unbounded + * cardinality). Returns null when the param is absent so callers can skip + * setting the attribute entirely — absence means "no UTM source", which is + * different from "unknown UTM source". + * + * Known server-side values: + * "plugin" — MCP server URL tagged by the sentry-for-ai AI plugin + */ +export function resolveUtmSource( + raw: string | null | undefined, +): string | null { + if (!raw) return null; + switch (raw) { + case "plugin": + return raw; + default: + return "other"; + } +} + +/** + * Convenience wrapper that reads utm_source directly from a URL object. + */ +export function resolveUtmSourceFromUrl(url: URL): string | null { + return resolveUtmSource(url.searchParams.get("utm_source")); +} diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index 1810ab1c..ed979315 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -28,6 +28,7 @@ import { checkRateLimit, } from "../utils/rate-limiter"; import { setSentryUserFromRequest } from "../utils/sentry-user"; +import { UTM_SOURCE_ATTRIBUTE, resolveUtmSourceFromUrl } from "./attribution"; import { resolveClientFamily } from "./client-family"; import { verifyConstraintsAccess } from "./constraint-utils"; @@ -202,6 +203,9 @@ const mcpHandler: ExportedHandler = { // Check for experimental mode query parameter const isExperimentalMode = url.searchParams.get("experimental") === "1"; + // Read utm_source for attribution tracking + const utmSource = resolveUtmSourceFromUrl(url); + // Extract OAuth props from ExecutionContext (set by OAuth provider) const oauthCtx = ctx as OAuthExecutionContext; @@ -232,6 +236,9 @@ const mcpHandler: ExportedHandler = { "app.server.mode.experimental", isExperimentalMode, ); + if (utmSource) { + activeSpan?.setAttribute(UTM_SOURCE_ATTRIBUTE, utmSource); + } // Parse and validate granted skills (primary authorization method) // Legacy tokens without grantedSkills are no longer supported diff --git a/packages/mcp-cloudflare/src/server/metrics.ts b/packages/mcp-cloudflare/src/server/metrics.ts index 55f9b21a..34b07fd6 100644 --- a/packages/mcp-cloudflare/src/server/metrics.ts +++ b/packages/mcp-cloudflare/src/server/metrics.ts @@ -1,4 +1,8 @@ import * as Sentry from "@sentry/cloudflare"; +import { + UTM_SOURCE_ATTRIBUTE, + resolveUtmSourceFromUrl, +} from "./lib/attribution"; import { resolveClientFamily } from "./lib/client-family"; import type { OAuthErrorTelemetry } from "./oauth/telemetry"; @@ -87,6 +91,7 @@ function getMcpRequestAttributes(request: Request, url: URL) { clientFamily: resolveClientFamily(request.headers.get("user-agent")), agentMode: url.searchParams.get("agent") === "1", experimentalMode: url.searchParams.get("experimental") === "1", + utmSource: resolveUtmSourceFromUrl(url), }; } @@ -132,6 +137,9 @@ function getMetricAttributes( attributes["app.server.mode.experimental"] = getBooleanAttribute( mcpAttributes.experimentalMode, ); + if (mcpAttributes.utmSource) { + attributes[UTM_SOURCE_ATTRIBUTE] = mcpAttributes.utmSource; + } } return attributes; @@ -184,6 +192,9 @@ export function annotateTrackedRequestSpan( "app.server.mode.experimental", mcpAttributes.experimentalMode, ); + if (mcpAttributes.utmSource) { + activeSpan.setAttribute(UTM_SOURCE_ATTRIBUTE, mcpAttributes.utmSource); + } } if (options?.responseReason) {