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
15 changes: 14 additions & 1 deletion packages/mcp-cloudflare/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
Expand Down
46 changes: 46 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
39 changes: 39 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/attribution.ts
Original file line number Diff line number Diff line change
@@ -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"));
}
7 changes: 7 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -202,6 +203,9 @@ const mcpHandler: ExportedHandler<Env> = {
// 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;

Expand Down Expand Up @@ -232,6 +236,9 @@ const mcpHandler: ExportedHandler<Env> = {
"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
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp-cloudflare/src/server/metrics.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading