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
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ process.on("exit", () => {

export { setLogger } from "./logger.js";
export { BrowserStackMcpServer } from "./server-factory.js";
export { trackMCP } from "./lib/instrumentation.js";
export { trackMCP, withToolCall } from "./lib/instrumentation.js";
export { instrumentToolLatency } from "./lib/tool-latency.js";
export { default as addTfaRcaCollaborationTools } from "./tools/tfa-rca-collaboration.js";
export const PackageJsonVersion = packageJson.version;
161 changes: 126 additions & 35 deletions src/lib/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import logger from "../logger.js";
import { getBrowserStackAuth } from "./get-auth.js";
import { createRequire } from "module";
Expand All @@ -6,6 +7,12 @@ const packageJson = require("../../package.json");
import { apiClient } from "./apiClient.js";
import globalConfig from "../config.js";

const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event";

export type ClientInfo = { name?: string; version?: string };

export type ToolOutcome = "ok" | "error_result" | "threw";

interface MCPEventPayload {
event_type: string;
event_properties: {
Expand All @@ -17,20 +24,78 @@ interface MCPEventPayload {
error_message?: string;
error_type?: string;
is_remote?: boolean;
duration_ms?: number;
outcome?: ToolOutcome;
};
}

function baseProperties(toolName: string, clientInfo: ClientInfo) {
return {
mcp_version: packageJson.version as string,
tool_name: toolName,
mcp_client: clientInfo?.name || "unknown",
node_version: process.versions.node,
is_remote: globalConfig.REMOTE_MCP,
};
}

function errorProperties(error: unknown) {
return {
error_message: error instanceof Error ? error.message : String(error),
error_type: error instanceof Error ? error.constructor.name : "Unknown",
};
}

function sendEvent(event: MCPEventPayload, config?: any): void {
let authHeader: string | undefined;
if (config) {
const authString = getBrowserStackAuth(config);
authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
}

apiClient
.post({
url: INSTRUMENTATION_ENDPOINT,
body: event,
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: authHeader } : {}),
},
timeout: 2000,
raise_error: false,
})
.catch(() => {});
}

/** Per-call state; AsyncLocalStorage keeps concurrent (multi-tenant) calls apart. */
interface CallContext {
toolName: string;
clientInfo: ClientInfo;
config?: any;
error?: unknown;
}

const callContext = new AsyncLocalStorage<CallContext>();

/**
* Inside `withToolCall` this only records into the call's context; the single row is
* written when the handler settles. Outside one (`started` heartbeat, unwrapped host
* tools) it posts a row immediately, as before.
*/
export function trackMCP(
toolName: string,
clientInfo: { name?: string; version?: string },
clientInfo: ClientInfo,
error?: unknown,
config?: any,
): void {
const instrumentationEndpoint = "https://api.browserstack.com/sdk/v1/event";
const isSuccess = !error;
const mcpClient = clientInfo?.name || "unknown";
const ctx = callContext.getStore();
if (ctx) {
if (clientInfo?.name && !ctx.clientInfo?.name) ctx.clientInfo = clientInfo;
if (config && !ctx.config) ctx.config = config;
if (error) ctx.error = error;
return;
}

// Log client information
if (clientInfo?.name) {
logger.info(
`Client connected: ${clientInfo.name} (version: ${clientInfo.version})`,
Expand All @@ -42,39 +107,65 @@ export function trackMCP(
const event: MCPEventPayload = {
event_type: "MCPInstrumentation",
event_properties: {
mcp_version: packageJson.version,
tool_name: toolName,
mcp_client: mcpClient,
node_version: process.versions.node,
success: isSuccess,
is_remote: globalConfig.REMOTE_MCP,
...baseProperties(toolName, clientInfo),
success: !error,
...(error ? errorProperties(error) : {}),
},
};
sendEvent(event, config);
}

// Add error details if applicable
if (error) {
event.event_properties.error_message =
error instanceof Error ? error.message : String(error);
event.event_properties.error_type =
error instanceof Error ? error.constructor.name : "Unknown";
}
function isErrorResult(result: unknown): boolean {
return (
typeof result === "object" &&
result !== null &&
(result as { isError?: unknown }).isError === true
);
}

let authHeader = undefined;
if (config) {
const authString = getBrowserStackAuth(config);
authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
/**
* Runs a tool handler and writes one MCPInstrumentation row when it settles: success,
* duration_ms, outcome (ok / error_result / threw), error fields on failure.
*/
export async function withToolCall<T>(
toolName: string,
getClientInfo: () => ClientInfo,
config: any,
fn: () => Promise<T> | T,
): Promise<T> {
const ctx: CallContext = { toolName, clientInfo: {}, config };
const startedAt = performance.now();
let outcome: ToolOutcome = "ok";
try {
const result = await callContext.run(ctx, fn);
if (isErrorResult(result)) outcome = "error_result";
return result;
} catch (error) {
outcome = "threw";
ctx.error ??= error;
throw error;
} finally {
try {
let clientInfo = ctx.clientInfo;
try {
const live = getClientInfo();
if (live?.name) clientInfo = live;
} catch {
/* client info is optional */
}
const event: MCPEventPayload = {
event_type: "MCPInstrumentation",
event_properties: {
...baseProperties(toolName, clientInfo),
success: ctx.error === undefined && outcome !== "threw",
duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
outcome,
...(ctx.error !== undefined ? errorProperties(ctx.error) : {}),
},
};
sendEvent(event, ctx.config ?? config);
} catch {
/* telemetry must never affect the call */
}
}

apiClient
.post({
url: instrumentationEndpoint,
body: event,
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: authHeader } : {}),
},
timeout: 2000,
raise_error: false,
})
.catch(() => {});
}
28 changes: 28 additions & 0 deletions src/lib/tool-latency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ClientInfo, withToolCall } from "./instrumentation.js";

const WRAPPED = Symbol.for("browserstack.mcp.latencyWrapped");

type AnyHandler = (...args: unknown[]) => unknown;

/** Wraps every function handler in `withToolCall`. Idempotent; skips task-style handlers. */
export function instrumentToolLatency(
tools: Record<string, RegisteredTool>,
getClientInfo: () => ClientInfo,
config?: unknown,
): void {
for (const [name, tool] of Object.entries(tools)) {
const inner = tool.handler as unknown;
if (typeof inner !== "function") continue;
if ((inner as AnyHandler & { [WRAPPED]?: true })[WRAPPED]) continue;

const wrapped: AnyHandler & { [WRAPPED]?: true } = (...args: unknown[]) =>
withToolCall(name, getClientInfo, config, () =>
(inner as AnyHandler)(...args),
);
wrapped[WRAPPED] = true;

// Direct assignment: tool.update() would also fire tools/list_changed.
(tool as { handler: unknown }).handler = wrapped;
}
}
8 changes: 8 additions & 0 deletions src/server-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { setupOnInitialized } from "./oninitialized.js";
import { BrowserStackConfig } from "./lib/types.js";
import addRCATools from "./tools/rca-agent.js";
import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
import { instrumentToolLatency } from "./lib/tool-latency.js";
import { nodeUpgradeNotice } from "./lib/node-version-notice.js";

/**
Expand Down Expand Up @@ -80,6 +81,13 @@ export class BrowserStackMcpServer {
);
Object.assign(this.tools, added);
});

// Client info is read at call time; it is empty until initialize arrives.
instrumentToolLatency(
this.tools,
() => this.server.server.getClientVersion() ?? {},
this.config,
);
}

/**
Expand Down
133 changes: 133 additions & 0 deletions tests/lib/instrumentation-single-row.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { trackMCP, withToolCall } from "../../src/lib/instrumentation";
import { apiClient } from "../../src/lib/apiClient";

vi.mock("../../src/lib/apiClient", () => ({
apiClient: { post: vi.fn().mockResolvedValue({ status: 200, data: {} }) },
}));
vi.mock("../../src/logger", () => ({
default: { info: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock("../../src/config", () => ({
default: { REMOTE_MCP: false },
}));

const clientInfo = { name: "claude-code", version: "1.2.3" };
const config = {
"browserstack-username": "user",
"browserstack-access-key": "key",
};
const rows = () =>
(apiClient.post as any).mock.calls.map(
(c: any) => c[0].body.event_properties,
);

describe("withToolCall", () => {
beforeEach(() => vi.clearAllMocks());

it("posts one MCPInstrumentation row with success, duration and outcome", async () => {
const out = await withToolCall(
"listTestCases",
() => clientInfo,
config,
async () => {
trackMCP("listTestCases", clientInfo, undefined, config);
return { content: [] };
},
);

expect(out).toEqual({ content: [] });
expect(apiClient.post).toHaveBeenCalledTimes(1);
const call = (apiClient.post as any).mock.calls[0][0];
expect(call.url).toBe("https://api.browserstack.com/sdk/v1/event");
expect(call.body.event_type).toBe("MCPInstrumentation");
expect(call.body.event_properties).toMatchObject({
tool_name: "listTestCases",
mcp_client: "claude-code",
is_remote: false,
success: true,
outcome: "ok",
});
expect(call.body.event_properties.duration_ms).toBeGreaterThanOrEqual(0);
expect(call.body.event_properties).not.toHaveProperty("phase");
expect(call.timeout).toBe(2000);
expect(call.raise_error).toBe(false);
});

it("folds the handler's catch-block trackMCP into the same row as a failure", async () => {
await withToolCall(
"fetchRCA",
() => clientInfo,
config,
async () => {
trackMCP("fetchRCA", clientInfo, undefined, config);
trackMCP(
"fetchRCA",
clientInfo,
new Error("Request failed with status code 401"),
config,
);
return { content: [], isError: true };
},
);

expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(rows()[0]).toMatchObject({
success: false,
outcome: "error_result",
error_message: "Request failed with status code 401",
});
});

it("uses the config and client the handler passed when the wrapper had none", async () => {
await withToolCall(
"t",
() => ({}),
undefined,
async () => {
trackMCP("t", { name: "cursor" }, undefined, config);
return { content: [] };
},
);
const call = (apiClient.post as any).mock.calls[0][0];
expect(call.body.event_properties.mcp_client).toBe("cursor");
expect(call.headers.Authorization).toMatch(/^Basic /);
});

it("rounds and clamps the duration", async () => {
await withToolCall(
"t",
() => clientInfo,
config,
() => ({ content: [] }),
);
const d = rows()[0].duration_ms;
expect(Number.isInteger(d)).toBe(true);
expect(d).toBeGreaterThanOrEqual(0);
});
});

describe("trackMCP outside an instrumented call", () => {
beforeEach(() => vi.clearAllMocks());

it("still posts the entry row immediately (heartbeat and unwrapped tools)", () => {
trackMCP("started", clientInfo, undefined, config);
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(rows()[0]).toMatchObject({ tool_name: "started", success: true });
expect(rows()[0]).not.toHaveProperty("duration_ms");
expect(rows()[0]).not.toHaveProperty("outcome");
});

it("still posts the failure row immediately", () => {
trackMCP(
"uploadAsset",
clientInfo,
new Error("x: 503 Service Unavailable"),
config,
);
expect(rows()[0]).toMatchObject({
success: false,
error_type: "Error",
});
});
});
Loading
Loading