/ hooks / toolPermission / permissionLogging.ts
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 }