Skip to content
Open
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
1 change: 1 addition & 0 deletions hooks/hook-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const HOSTS = {
manifest: ".claude-plugin/hooks.json",
format: "nested",
rootVariable: "CLAUDE_PLUGIN_ROOT",
telemetry: "claude-code",
contextOutput: (eventName, text) => ({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
}),
Expand Down
122 changes: 122 additions & 0 deletions hooks/telemetry-adapters/claude-code.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @ts-check

import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { serviceForToolkit, serviceForToolName } from "../telemetry-classify.mjs";
import { BASH_CLIS, GATEWAY_TOOLS } from "../telemetry-contract.mjs";
import {
arcadeToolProperties,
builtinToolProperties as sharedBuiltinToolProperties,
otherServerProperties,
} from "../telemetry-events.mjs";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const plugin = JSON.parse(readFileSync(path.join(ROOT, "plugin.json"), "utf8"));
const [mcpServerKey] = Object.keys(JSON.parse(readFileSync(path.join(ROOT, "mcp.json"), "utf8")).mcpServers);

export const ARCADE_TOOL_PREFIX = `mcp__plugin_${plugin.name}_${mcpServerKey}__`;
export const CLAUDE_AI_ARCADE_TOOL_PREFIX = "mcp__claude_ai_arcade__";

const preToolMatcher = `^(?:${ARCADE_TOOL_PREFIX}|${CLAUDE_AI_ARCADE_TOOL_PREFIX})`;

/**
* @param {unknown} toolName
* @param {Record<string, any> | undefined} toolInput
*/
const claudeToolProperties = (toolName, toolInput) => {
if (typeof toolName !== "string" || !toolName.startsWith("mcp__")) {
return null;
}
const splitAt = toolName.lastIndexOf("__");
const tool = toolName.slice(splitAt + 2);
if (toolName.startsWith(ARCADE_TOOL_PREFIX)) {
return arcadeToolProperties("arcade", tool, toolInput);
}
if (toolName.startsWith(CLAUDE_AI_ARCADE_TOOL_PREFIX)) {
return arcadeToolProperties("other_arcade", tool, toolInput);
}
if (/** @type {readonly string[]} */ (GATEWAY_TOOLS).includes(tool)) {
return arcadeToolProperties("other_arcade", tool, toolInput);
}
// Some connectors name the service in the server, not the tool, e.g.
// mcp__claude_ai_Gmail__search_threads. Only the category is sent.
const serverParts = toolName.slice("mcp__".length, splitAt).split(/[^A-Za-z0-9]+/);
const service =
serviceForToolName(tool) ?? serverParts.map(serviceForToolkit).find(Boolean);
return otherServerProperties(service ?? undefined);
};

/**
* @param {unknown} toolName
* @param {Record<string, any> | undefined} toolInput
*/
const claudeAttemptProperties = (toolName, toolInput) => {
if (typeof toolName !== "string") return null;
if (toolName.startsWith(ARCADE_TOOL_PREFIX)) {
return arcadeToolProperties("arcade", toolName.slice(ARCADE_TOOL_PREFIX.length), toolInput);
}
if (toolName.startsWith(CLAUDE_AI_ARCADE_TOOL_PREFIX)) {
return arcadeToolProperties("other_arcade", toolName.slice(CLAUDE_AI_ARCADE_TOOL_PREFIX.length), toolInput);
}
return null;
};

/** @param {Record<string, any>} raw */
const normalize = (raw) => ({
hook_event_name: raw.hook_event_name,
session_id: raw.session_id,
prompt_id: raw.prompt_id,
source: raw.source,
prompt: raw.prompt,
tool_name: raw.tool_name,
tool_input: raw.tool_input,
tool_response: raw.tool_response,
error: raw.error,
is_interrupt: raw.is_interrupt,
agent_type: raw.agent_type,
agent_id: raw.agent_id,
last_assistant_message: raw.last_assistant_message,
});

// `if` is a Claude Code permission rule so the hook does not start for other commands;
// `extraArgs` pass the CLI name through to builtinToolProperties.
/** @param {"PostToolUse" | "PostToolUseFailure"} event */
const bashHookRows = (event) =>
BASH_CLIS.map((cli) => ({
event,
matcher: "Bash",
if: `Bash(${cli} *)`,
extraArgs: ["--cli", cli],
}));

/** @type {import("../telemetry-adapter.mjs").TelemetryAdapter} */
const claudeCodeAdapter = {
host: "claude-code",
dataVariable: "CLAUDE_PLUGIN_DATA",
// Claude Code reads these as set for any non-empty value, even "0" or "false".
optOutSwitches: [
{ name: "DISABLE_TELEMETRY", anyValue: true },
{ name: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", anyValue: true },
],
requiresTurn: true,
promptReminder: true,
subagentSession: false,
hookRows: [
{ event: "UserPromptSubmit" },
{ event: "PreToolUse", matcher: preToolMatcher },
{ event: "PostToolUse", matcher: "mcp__.*" },
{ event: "PostToolUse", matcher: "WebFetch|WebSearch" },
...bashHookRows("PostToolUse"),
{ event: "PostToolUseFailure", matcher: "mcp__.*" },
{ event: "PostToolUseFailure", matcher: "WebFetch|WebSearch" },
...bashHookRows("PostToolUseFailure"),
{ event: "SubagentStop" },
],
normalize,
toolProperties: claudeToolProperties,
attemptProperties: claudeAttemptProperties,
builtinToolProperties: sharedBuiltinToolProperties,
};

export default claudeCodeAdapter;
10 changes: 10 additions & 0 deletions test/fixtures/telemetry/claude-code/auth-status-response.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Text of a System_ManageAuthorization status answer, as tool_response. */
export const authStatusResponse = (statuses) => [
{
type: "text",
text: JSON.stringify({
message: statuses.includes("authorization_required") ? "Not yet authorized: dropbox." : "All authorized.",
providers: statuses.map((status, index) => ({ provider: `provider${index}`, status })),
}),
},
];
154 changes: 154 additions & 0 deletions test/fixtures/telemetry/claude-code/event-cases.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
ARCADE_TOOL_PREFIX,
CLAUDE_AI_ARCADE_TOOL_PREFIX,
} from "../../../../hooks/telemetry-adapters/claude-code.mjs";
import { authStatusResponse } from "./auth-status-response.mjs";

const SIGN_IN = `${ARCADE_TOOL_PREFIX}System_ManageAuthorization`;
const OPERATOR = "arcade:arcade-operator";

const tool = (name, toolInput) => ({ hook_event_name: "PostToolUse", tool_name: name, tool_input: toolInput });
const failed = (name, error, fields = {}) => ({
hook_event_name: "PostToolUseFailure",
tool_name: name,
error,
...fields,
});
const signInCheck = (name, statuses) => ({
hook_event_name: "PostToolUse",
tool_name: name,
tool_response: authStatusResponse(statuses),
});
const stop = (message) => ({
hook_event_name: "SubagentStop",
agent_type: OPERATOR,
last_assistant_message: message,
});
const operator = (status) => ({ agent: "arcade-operator", status });

/**
* @typedef {object} EventCase
* @property {string} id
* @property {Record<string, unknown>} input
* @property {null | { event: string, extra: Record<string, unknown> }} expected
* @property {string[]} [argv]
* @property {boolean} [seedAppWork]
*/

/** @returns {EventCase[]} */
export const eventCases = () => {
const ATTEMPTED = "Plugin tool attempted";
const CALLED = "Plugin tool called";
const FAILED = "Plugin tool failed";
const STOPPED = "Plugin subagent stopped";
/** @type {EventCase[]} */
const cases = [
{ id: "session-start-startup", input: { hook_event_name: "SessionStart", source: "startup" }, expected: null },
{ id: "prompt-app-work", input: { hook_event_name: "UserPromptSubmit", prompt: "What is on my calendar tomorrow?" },
expected: { event: "Plugin prompt submitted", extra: { could_use_arcade: true, service_hints: ["calendar"], reminder_sent: true } } },
{ id: "prompt-confirmation-style", input: { hook_event_name: "UserPromptSubmit", prompt: "ok", prompt_id: "confirm-turn" },
expected: { event: "Plugin prompt submitted", extra: { could_use_arcade: false, service_hints: [], reminder_sent: false } },
seedAppWork: true },
{ id: "prompt-unrelated", input: { hook_event_name: "UserPromptSubmit", prompt: "Fix the parser" }, expected: null },
{ id: "prompt-task-notification", input: {
hook_event_name: "UserPromptSubmit",
prompt: "<task-notification>\n<status>completed</status> calendar",
}, expected: null },
{ id: "pre-arcade-select", input: { hook_event_name: "PreToolUse", tool_name: `${ARCADE_TOOL_PREFIX}Arcade_SelectTools` },
expected: { event: ATTEMPTED, extra: { server: "arcade", tool: "Arcade_SelectTools" } } },
{ id: "pre-claude-ai-select", input: { hook_event_name: "PreToolUse", tool_name: `${CLAUDE_AI_ARCADE_TOOL_PREFIX}Arcade_SelectTools` },
expected: { event: ATTEMPTED, extra: { server: "other_arcade", tool: "Arcade_SelectTools" } } },
{ id: "pre-claude-ai-calendar", input: { hook_event_name: "PreToolUse", tool_name: `${CLAUDE_AI_ARCADE_TOOL_PREFIX}GoogleCalendar_ListEvents` },
expected: { event: ATTEMPTED, extra: { server: "other_arcade", tool: "app_tool", service: "calendar" } } },
{ id: "pre-use-tool", input: {
hook_event_name: "PreToolUse",
tool_name: `${ARCADE_TOOL_PREFIX}Arcade_UseTool`,
tool_input: { tool_name: "GoogleCalendar.ListEvents" },
}, expected: { event: ATTEMPTED, extra: { server: "arcade", tool: "Arcade_UseTool", service: "calendar" } } },
{ id: "pre-other-arcade-tool", input: { hook_event_name: "PreToolUse", tool_name: `${CLAUDE_AI_ARCADE_TOOL_PREFIX}AcmeHR_RunPayroll` },
expected: { event: ATTEMPTED, extra: { server: "other_arcade", tool: "other" } } },
{ id: "pre-gmail-connector", input: { hook_event_name: "PreToolUse", tool_name: "mcp__claude_ai_Gmail__search_threads" }, expected: null },
{ id: "pre-wrong-arcade-connector", input: { hook_event_name: "PreToolUse", tool_name: "mcp__claude_ai_Arcade__Arcade_SelectTools" }, expected: null },
{ id: "post-gmail", input: tool(`${ARCADE_TOOL_PREFIX}Gmail_ListEmails`),
expected: { event: CALLED, extra: { server: "arcade", tool: "app_tool", service: "email" } } },
{ id: "post-claude-ai-calendar", input: tool(`${CLAUDE_AI_ARCADE_TOOL_PREFIX}GoogleCalendar_ListEvents`),
expected: { event: CALLED, extra: { server: "other_arcade", tool: "app_tool", service: "calendar" } } },
{ id: "post-sign-in-no-auth", input: tool(SIGN_IN),
expected: { event: CALLED, extra: { server: "arcade", tool: "System_ManageAuthorization", auth_needed: false } } },
{ id: "post-sign-in-auth-needed", input: signInCheck(SIGN_IN, ["authorized", "authorization_required"]),
expected: { event: CALLED, extra: { server: "arcade", tool: "System_ManageAuthorization", auth_needed: true } } },
{ id: "post-sign-in-authorized", input: signInCheck(SIGN_IN, ["authorized"]),
expected: { event: CALLED, extra: { server: "arcade", tool: "System_ManageAuthorization", auth_needed: false } } },
{ id: "post-claude-ai-sign-in", input: signInCheck("mcp__claude_ai_Arcade__System_ManageAuthorization", ["authorization_required"]),
expected: { event: CALLED, extra: { server: "other_arcade", tool: "System_ManageAuthorization", auth_needed: true } } },
{ id: "post-use-tool-gmail", input: tool(`${ARCADE_TOOL_PREFIX}Arcade_UseTool`, { tool_name: "Gmail.ListEmails" }),
expected: { event: CALLED, extra: { server: "arcade", tool: "Arcade_UseTool", service: "email" } } },
{ id: "post-use-tool-calendar", input: tool(`${ARCADE_TOOL_PREFIX}Arcade_UseTool`, { tool_name: "GoogleCalendar.ListEvents" }),
expected: { event: CALLED, extra: { server: "arcade", tool: "Arcade_UseTool", service: "calendar" } } },
{ id: "post-use-tool-unknown", input: tool(`${ARCADE_TOOL_PREFIX}Arcade_UseTool`, { tool_name: "AcmeHR.RunPayroll" }),
expected: { event: CALLED, extra: { server: "arcade", tool: "Arcade_UseTool" } } },
{ id: "post-other-arcade-app", input: tool(`${ARCADE_TOOL_PREFIX}AcmeHR_RunPayroll`),
expected: { event: CALLED, extra: { server: "arcade", tool: "other" } } },
{ id: "post-claude-ai-use-tool", input: tool("mcp__claude_ai_Arcade_Production__Arcade_UseTool", { tool_name: "Slack_SendMessage" }),
expected: { event: CALLED, extra: { server: "other_arcade", tool: "Arcade_UseTool", service: "chat" } } },
{ id: "post-other-server-granola", input: tool("mcp__granola__Granola_ListMeetings"),
expected: { event: CALLED, extra: { server: "other", service: "meetings" } }, seedAppWork: true },
{ id: "post-gmail-connector", input: tool("mcp__claude_ai_Gmail__search_threads"),
expected: { event: CALLED, extra: { server: "other", service: "email" } }, seedAppWork: true },
{ id: "post-other-server-unknown", input: tool("mcp__secret-server__DoThing"),
expected: { event: CALLED, extra: { server: "other" } }, seedAppWork: true },
{ id: "post-read-builtin", input: tool("Read"), expected: null },
{ id: "failure-slack", input: { hook_event_name: "PostToolUseFailure", tool_name: `${ARCADE_TOOL_PREFIX}Slack_SendMessage` },
expected: { event: FAILED, extra: { server: "arcade", tool: "app_tool", service: "chat", failure_kind: "tool_error" } } },
{ id: "failure-auth", input: failed(`${ARCADE_TOOL_PREFIX}Gmail_ListEmails`,
'{"message":"The tool was not executed because it requires authorization.","authorization_url":"https://example.com/auth"}'),
expected: { event: FAILED, extra: { server: "arcade", tool: "app_tool", service: "email", failure_kind: "auth_required" } } },
{ id: "failure-session-expired", input: failed(`${ARCADE_TOOL_PREFIX}Arcade_UseTool`, 'MCP server "plugin:arcade:arcade" session expired',
{ tool_input: { tool_name: "Gmail.ListEmails" } }),
expected: { event: FAILED, extra: { server: "arcade", tool: "Arcade_UseTool", service: "email", failure_kind: "session_expired" } } },
{ id: "failure-unreachable", input: failed(`${ARCADE_TOOL_PREFIX}Gmail_ListEmails`, "Connection closed"),
expected: { event: FAILED, extra: { server: "arcade", tool: "app_tool", service: "email", failure_kind: "unreachable" } } },
{ id: "failure-interrupted", input: failed(`${ARCADE_TOOL_PREFIX}Gmail_ListEmails`, "Connection closed", { is_interrupt: true }),
expected: { event: FAILED, extra: { server: "arcade", tool: "app_tool", service: "email", failure_kind: "interrupted" } } },
{ id: "failure-other-http", input: failed("mcp__secret-server__DoThing", "Error POSTing to endpoint: internal error"),
expected: { event: FAILED, extra: { server: "other", failure_kind: "http_error" } }, seedAppWork: true },
{ id: "failure-read", input: failed("Read", "File does not exist."), expected: null },
{ id: "subagent-operator-needs-auth", input: stop("Done.\n\nstatus: needs_auth\nsummary: sign in"),
expected: { event: STOPPED, extra: operator("needs_auth") } },
{ id: "subagent-operator-needs-confirmation", input: stop("**status:** needs_confirmation"),
expected: { event: STOPPED, extra: operator("needs_confirmation") } },
{ id: "subagent-operator-needs-clarification", input: stop("- status: `needs_clarification`"),
expected: { event: STOPPED, extra: operator("needs_clarification") } },
{ id: "subagent-operator-failed", input: stop("STATUS: FAILED"),
expected: { event: STOPPED, extra: operator("failed") } },
{ id: "subagent-operator-unknown", input: stop("status: exploded"),
expected: { event: STOPPED, extra: operator("unknown") } },
{ id: "subagent-operator-no-message", input: stop(undefined),
expected: { event: STOPPED, extra: operator("unknown") } },
{ id: "subagent-non-operator", input: {
hook_event_name: "SubagentStop",
agent_type: "general-purpose",
last_assistant_message: "status: completed",
}, expected: null },
{ id: "subagent-operator-completed", input: {
hook_event_name: "SubagentStop",
agent_type: OPERATOR,
agent_id: "agent-1",
last_assistant_message: "status: completed",
}, expected: { event: STOPPED, extra: operator("completed") } },
{ id: "builtin-webfetch", input: { hook_event_name: "PostToolUse", tool_name: "WebFetch", tool_input: { url: "https://example.com" } },
expected: { event: "Plugin built-in tool called", extra: { tool: "WebFetch" } }, seedAppWork: true },
{ id: "builtin-websearch", input: { hook_event_name: "PostToolUse", tool_name: "WebSearch", tool_input: { query: "weather" } },
expected: { event: "Plugin built-in tool called", extra: { tool: "WebSearch" } }, seedAppWork: true },
{ id: "builtin-bash-gh", input: { hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "gh pr list" } },
expected: { event: "Plugin built-in tool called", extra: { tool: "Bash", cli: "gh", service: "code_hosting" } },
argv: ["--cli", "gh"], seedAppWork: true },
{ id: "builtin-bash-curl", input: { hook_event_name: "PostToolUseFailure", tool_name: "Bash", tool_input: { command: "curl x" } },
expected: { event: "Plugin built-in tool failed", extra: { tool: "Bash", cli: "curl" } },
argv: ["--cli", "curl"], seedAppWork: true },
{ id: "builtin-bash-non-listed", input: { hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "echo hi" } },
expected: null, argv: ["--cli", "gh"], seedAppWork: true },
{ id: "session-start-compact", input: { hook_event_name: "SessionStart", source: "compact" }, expected: null },
];
return cases;
};
Loading
Loading