permissionLogging.ts
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. 4 import { feature } from 'bun:bundle' 5 import { 6 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7 logEvent, 8 } from 'src/services/analytics/index.js' 9 import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' 10 import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js' 11 import type { Tool as ToolType, ToolUseContext } from '../../Tool.js' 12 import { getLanguageName } from '../../utils/cliHighlight.js' 13 import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 14 import { logOTelEvent } from '../../utils/telemetry/events.js' 15 import type { 16 PermissionApprovalSource, 17 PermissionRejectionSource, 18 } from './PermissionContext.js' 19 20 type 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 29 type PermissionDecisionArgs = 30 | { decision: 'accept'; source: PermissionApprovalSource | 'config' } 31 | { decision: 'reject'; source: PermissionRejectionSource | 'config' } 32 33 const CODE_EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit'] 34 35 function 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 41 async 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 68 function 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 91 function 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 107 function 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 152 function 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. 181 function 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 237 export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision } 238 export type { PermissionLogContext, PermissionDecisionArgs }