source dump of claude code
at main 238 lines 7.3 kB view raw
1// Centralized analytics/telemetry logging for tool permission decisions. 2// All permission approve/reject events flow through logPermissionDecision(), 3// which fans out to Statsig analytics, OTel telemetry, and code-edit metrics. 4import { feature } from 'bun:bundle' 5import { 6 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7 logEvent, 8} from 'src/services/analytics/index.js' 9import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' 10import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js' 11import type { Tool as ToolType, ToolUseContext } from '../../Tool.js' 12import { getLanguageName } from '../../utils/cliHighlight.js' 13import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 14import { logOTelEvent } from '../../utils/telemetry/events.js' 15import type { 16 PermissionApprovalSource, 17 PermissionRejectionSource, 18} from './PermissionContext.js' 19 20type PermissionLogContext = { 21 tool: ToolType 22 input: unknown 23 toolUseContext: ToolUseContext 24 messageId: string 25 toolUseID: string 26} 27 28// Discriminated union: 'accept' pairs with approval sources, 'reject' with rejection sources 29type PermissionDecisionArgs = 30 | { decision: 'accept'; source: PermissionApprovalSource | 'config' } 31 | { decision: 'reject'; source: PermissionRejectionSource | 'config' } 32 33const CODE_EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit'] 34 35function isCodeEditingTool(toolName: string): boolean { 36 return CODE_EDITING_TOOLS.includes(toolName) 37} 38 39// Builds OTel counter attributes for code editing tools, enriching with 40// language when the tool's target file path can be extracted from input 41async function buildCodeEditToolAttributes( 42 tool: ToolType, 43 input: unknown, 44 decision: 'accept' | 'reject', 45 source: string, 46): Promise<Record<string, string>> { 47 // Derive language from file path if the tool exposes one (e.g., Edit, Write) 48 let language: string | undefined 49 if (tool.getPath && input) { 50 const parseResult = tool.inputSchema.safeParse(input) 51 if (parseResult.success) { 52 const filePath = tool.getPath(parseResult.data) 53 if (filePath) { 54 language = await getLanguageName(filePath) 55 } 56 } 57 } 58 59 return { 60 decision, 61 source, 62 tool_name: tool.name, 63 ...(language && { language }), 64 } 65} 66 67// Flattens structured source into a string label for analytics/OTel events 68function sourceToString( 69 source: PermissionApprovalSource | PermissionRejectionSource, 70): string { 71 if ( 72 (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && 73 source.type === 'classifier' 74 ) { 75 return 'classifier' 76 } 77 switch (source.type) { 78 case 'hook': 79 return 'hook' 80 case 'user': 81 return source.permanent ? 'user_permanent' : 'user_temporary' 82 case 'user_abort': 83 return 'user_abort' 84 case 'user_reject': 85 return 'user_reject' 86 default: 87 return 'unknown' 88 } 89} 90 91function baseMetadata( 92 messageId: string, 93 toolName: string, 94 waitMs: number | undefined, 95): { [key: string]: boolean | number | undefined } { 96 return { 97 messageID: 98 messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 99 toolName: sanitizeToolNameForAnalytics(toolName), 100 sandboxEnabled: SandboxManager.isSandboxingEnabled(), 101 // Only include wait time when the user was actually prompted (not auto-approved) 102 ...(waitMs !== undefined && { waiting_for_user_permission_ms: waitMs }), 103 } 104} 105 106// Emits a distinct analytics event name per approval source for funnel analysis 107function logApprovalEvent( 108 tool: ToolType, 109 messageId: string, 110 source: PermissionApprovalSource | 'config', 111 waitMs: number | undefined, 112): void { 113 if (source === 'config') { 114 // Auto-approved by allowlist in settings -- no user wait time 115 logEvent( 116 'tengu_tool_use_granted_in_config', 117 baseMetadata(messageId, tool.name, undefined), 118 ) 119 return 120 } 121 if ( 122 (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && 123 source.type === 'classifier' 124 ) { 125 logEvent( 126 'tengu_tool_use_granted_by_classifier', 127 baseMetadata(messageId, tool.name, waitMs), 128 ) 129 return 130 } 131 switch (source.type) { 132 case 'user': 133 logEvent( 134 source.permanent 135 ? 'tengu_tool_use_granted_in_prompt_permanent' 136 : 'tengu_tool_use_granted_in_prompt_temporary', 137 baseMetadata(messageId, tool.name, waitMs), 138 ) 139 break 140 case 'hook': 141 logEvent('tengu_tool_use_granted_by_permission_hook', { 142 ...baseMetadata(messageId, tool.name, waitMs), 143 permanent: source.permanent ?? false, 144 }) 145 break 146 default: 147 break 148 } 149} 150 151// Rejections share a single event name, differentiated by metadata fields 152function logRejectionEvent( 153 tool: ToolType, 154 messageId: string, 155 source: PermissionRejectionSource | 'config', 156 waitMs: number | undefined, 157): void { 158 if (source === 'config') { 159 // Denied by denylist in settings 160 logEvent( 161 'tengu_tool_use_denied_in_config', 162 baseMetadata(messageId, tool.name, undefined), 163 ) 164 return 165 } 166 logEvent('tengu_tool_use_rejected_in_prompt', { 167 ...baseMetadata(messageId, tool.name, waitMs), 168 // Distinguish hook rejections from user rejections via separate fields 169 ...(source.type === 'hook' 170 ? { isHook: true } 171 : { 172 hasFeedback: 173 source.type === 'user_reject' ? source.hasFeedback : false, 174 }), 175 }) 176} 177 178// Single entry point for all permission decision logging. Called by permission 179// handlers after every approve/reject. Fans out to: analytics events, OTel 180// telemetry, code-edit OTel counters, and toolUseContext decision storage. 181function logPermissionDecision( 182 ctx: PermissionLogContext, 183 args: PermissionDecisionArgs, 184 permissionPromptStartTimeMs?: number, 185): void { 186 const { tool, input, toolUseContext, messageId, toolUseID } = ctx 187 const { decision, source } = args 188 189 const waiting_for_user_permission_ms = 190 permissionPromptStartTimeMs !== undefined 191 ? Date.now() - permissionPromptStartTimeMs 192 : undefined 193 194 // Log the analytics event 195 if (args.decision === 'accept') { 196 logApprovalEvent( 197 tool, 198 messageId, 199 args.source, 200 waiting_for_user_permission_ms, 201 ) 202 } else { 203 logRejectionEvent( 204 tool, 205 messageId, 206 args.source, 207 waiting_for_user_permission_ms, 208 ) 209 } 210 211 const sourceString = source === 'config' ? 'config' : sourceToString(source) 212 213 // Track code editing tool metrics 214 if (isCodeEditingTool(tool.name)) { 215 void buildCodeEditToolAttributes(tool, input, decision, sourceString).then( 216 attributes => getCodeEditToolDecisionCounter()?.add(1, attributes), 217 ) 218 } 219 220 // Persist decision on the context so downstream code can inspect what happened 221 if (!toolUseContext.toolDecisions) { 222 toolUseContext.toolDecisions = new Map() 223 } 224 toolUseContext.toolDecisions.set(toolUseID, { 225 source: sourceString, 226 decision, 227 timestamp: Date.now(), 228 }) 229 230 void logOTelEvent('tool_decision', { 231 decision, 232 source: sourceString, 233 tool_name: sanitizeToolNameForAnalytics(tool.name), 234 }) 235} 236 237export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision } 238export type { PermissionLogContext, PermissionDecisionArgs }