📄 File detail
hooks/toolPermission/permissionLogging.ts
🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes isCodeEditingTool, buildCodeEditToolAttributes, and logPermissionDecision — mainly functions, hooks, or classes. Dependencies touch bun:bundle and src. It composes internal code from bootstrap, Tool, utils, and PermissionContext (relative imports). What the file header says: Centralized analytics/telemetry logging for tool permission decisions. All permission approve/reject events flow through logPermissionDecision(), which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
Centralized analytics/telemetry logging for tool permission decisions. All permission approve/reject events flow through logPermissionDecision(), which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
📤 Exports (heuristic)
isCodeEditingToolbuildCodeEditToolAttributeslogPermissionDecision
📚 External import roots
Package roots from from "…" (relative paths omitted).
bun:bundlesrc
🖥️ Source preview
// Centralized analytics/telemetry logging for tool permission decisions.
// All permission approve/reject events flow through logPermissionDecision(),
// which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
import { feature } from 'bun:bundle'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js'
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
import { getLanguageName } from '../../utils/cliHighlight.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { logOTelEvent } from '../../utils/telemetry/events.js'
import type {
PermissionApprovalSource,
PermissionRejectionSource,
} from './PermissionContext.js'
type PermissionLogContext = {
tool: ToolType
input: unknown
toolUseContext: ToolUseContext
messageId: string
toolUseID: string
}
// Discriminated union: 'accept' pairs with approval sources, 'reject' with rejection sources
type PermissionDecisionArgs =
| { decision: 'accept'; source: PermissionApprovalSource | 'config' }
| { decision: 'reject'; source: PermissionRejectionSource | 'config' }
const CODE_EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
function isCodeEditingTool(toolName: string): boolean {
return CODE_EDITING_TOOLS.includes(toolName)
}
// Builds OTel counter attributes for code editing tools, enriching with
// language when the tool's target file path can be extracted from input
async function buildCodeEditToolAttributes(
tool: ToolType,
input: unknown,
decision: 'accept' | 'reject',
source: string,
): Promise<Record<string, string>> {
// Derive language from file path if the tool exposes one (e.g., Edit, Write)
let language: string | undefined
if (tool.getPath && input) {
const parseResult = tool.inputSchema.safeParse(input)
if (parseResult.success) {
const filePath = tool.getPath(parseResult.data)
if (filePath) {
language = await getLanguageName(filePath)
}
}
}
return {
decision,
source,
tool_name: tool.name,
...(language && { language }),
}
}
// Flattens structured source into a string label for analytics/OTel events
function sourceToString(
source: PermissionApprovalSource | PermissionRejectionSource,
): string {
if (
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
source.type === 'classifier'
) {
return 'classifier'
}
switch (source.type) {
case 'hook':
return 'hook'
case 'user':
return source.permanent ? 'user_permanent' : 'user_temporary'
case 'user_abort':
return 'user_abort'
case 'user_reject':
return 'user_reject'
default:
return 'unknown'
}
}
function baseMetadata(
messageId: string,
toolName: string,
waitMs: number | undefined,
): { [key: string]: boolean | number | undefined } {
return {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName: sanitizeToolNameForAnalytics(toolName),
sandboxEnabled: SandboxManager.isSandboxingEnabled(),
// Only include wait time when the user was actually prompted (not auto-approved)
...(waitMs !== undefined && { waiting_for_user_permission_ms: waitMs }),
}
}
// Emits a distinct analytics event name per approval source for funnel analysis
function logApprovalEvent(
tool: ToolType,
messageId: string,
source: PermissionApprovalSource | 'config',
waitMs: number | undefined,
): void {
if (source === 'config') {
// Auto-approved by allowlist in settings -- no user wait time
logEvent(
'tengu_tool_use_granted_in_config',
baseMetadata(messageId, tool.name, undefined),
)
return
}
if (
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
source.type === 'classifier'
) {
logEvent(
'tengu_tool_use_granted_by_classifier',
baseMetadata(messageId, tool.name, waitMs),
)
return
}
switch (source.type) {
case 'user':
logEvent(
source.permanent
? 'tengu_tool_use_granted_in_prompt_permanent'
: 'tengu_tool_use_granted_in_prompt_temporary',
baseMetadata(messageId, tool.name, waitMs),
)
break
case 'hook':
logEvent('tengu_tool_use_granted_by_permission_hook', {
...baseMetadata(messageId, tool.name, waitMs),
permanent: source.permanent ?? false,
})
break
default:
break
}
}
// Rejections share a single event name, differentiated by metadata fields
function logRejectionEvent(
tool: ToolType,
messageId: string,
source: PermissionRejectionSource | 'config',
waitMs: number | undefined,
): void {
if (source === 'config') {
// Denied by denylist in settings
logEvent(
'tengu_tool_use_denied_in_config',
baseMetadata(messageId, tool.name, undefined),
)
return
}
logEvent('tengu_tool_use_rejected_in_prompt', {
...baseMetadata(messageId, tool.name, waitMs),
// Distinguish hook rejections from user rejections via separate fields
...(source.type === 'hook'
? { isHook: true }
: {
hasFeedback:
source.type === 'user_reject' ? source.hasFeedback : false,
}),
})
}
// Single entry point for all permission decision logging. Called by permission
// handlers after every approve/reject. Fans out to: analytics events, OTel
// telemetry, code-edit OTel counters, and toolUseContext decision storage.
function logPermissionDecision(
ctx: PermissionLogContext,
args: PermissionDecisionArgs,
permissionPromptStartTimeMs?: number,
): void {
const { tool, input, toolUseContext, messageId, toolUseID } = ctx
const { decision, source } = args
const waiting_for_user_permission_ms =
permissionPromptStartTimeMs !== undefined
? Date.now() - permissionPromptStartTimeMs
: undefined
// Log the analytics event
if (args.decision === 'accept') {
logApprovalEvent(
tool,
messageId,
args.source,
waiting_for_user_permission_ms,
)
} else {
logRejectionEvent(
tool,
messageId,
args.source,
waiting_for_user_permission_ms,
)
}
const sourceString = source === 'config' ? 'config' : sourceToString(source)
// Track code editing tool metrics
if (isCodeEditingTool(tool.name)) {
void buildCodeEditToolAttributes(tool, input, decision, sourceString).then(
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes),
)
}
// Persist decision on the context so downstream code can inspect what happened
if (!toolUseContext.toolDecisions) {
toolUseContext.toolDecisions = new Map()
}
toolUseContext.toolDecisions.set(toolUseID, {
source: sourceString,
decision,
timestamp: Date.now(),
})
void logOTelEvent('tool_decision', {
decision,
source: sourceString,
tool_name: sanitizeToolNameForAnalytics(tool.name),
})
}
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
export type { PermissionLogContext, PermissionDecisionArgs }