source dump of claude code
at main 289 lines 10 kB view raw
1/** 2 * Plugin telemetry helpers — shared field builders for plugin lifecycle events. 3 * 4 * Implements the twin-column privacy pattern: every user-defined-name field 5 * emits both a raw value (routed to PII-tagged _PROTO_* BQ columns) and a 6 * redacted twin (real name iff marketplace ∈ allowlist, else 'third-party'). 7 * 8 * plugin_id_hash provides an opaque per-plugin aggregation key with no privacy 9 * dependency — sha256(name@marketplace + FIXED_SALT) truncated to 16 chars. 10 * This answers distinct-count and per-plugin-trend questions that the 11 * redacted column can't, without exposing user-defined names. 12 */ 13 14import { createHash } from 'crypto' 15import { sep } from 'path' 16import { 17 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 18 type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 19 logEvent, 20} from '../../services/analytics/index.js' 21import type { 22 LoadedPlugin, 23 PluginError, 24 PluginManifest, 25} from '../../types/plugin.js' 26import { 27 isOfficialMarketplaceName, 28 parsePluginIdentifier, 29} from '../plugins/pluginIdentifier.js' 30 31// builtinPlugins.ts:BUILTIN_MARKETPLACE_NAME — inlined to avoid the cycle 32// through commands.js. Marketplace schemas.ts enforces 'builtin' is reserved. 33const BUILTIN_MARKETPLACE_NAME = 'builtin' 34 35// Fixed salt for plugin_id_hash. Same constant across all repos and emission 36// sites. Not per-org, not rotated — per-org salt would defeat cross-org 37// distinct-count, rotation would break trend lines. Customers can compute the 38// same hash on their known plugin names to reverse-match their own telemetry. 39const PLUGIN_ID_HASH_SALT = 'claude-plugin-telemetry-v1' 40 41/** 42 * Opaque per-plugin aggregation key. Input is the name@marketplace string as 43 * it appears in enabledPlugins keys, lowercased on the marketplace suffix for 44 * reproducibility. 16-char truncation keeps BQ GROUP BY cardinality manageable 45 * while making collisions negligible at projected 10k-plugin scale. Name case 46 * is preserved in both branches (enabledPlugins keys are case-sensitive). 47 */ 48export function hashPluginId(name: string, marketplace?: string): string { 49 const key = marketplace ? `${name}@${marketplace.toLowerCase()}` : name 50 return createHash('sha256') 51 .update(key + PLUGIN_ID_HASH_SALT) 52 .digest('hex') 53 .slice(0, 16) 54} 55 56/** 57 * 4-value scope enum for plugin origin. Distinct from PluginScope 58 * (managed/user/project/local) which is installation-target — this is 59 * marketplace-origin. 60 * 61 * - official: from an allowlisted Anthropic marketplace 62 * - default-bundle: ships with product (@builtin), auto-enabled 63 * - org: enterprise admin-pushed via managed settings (policySettings) 64 * - user-local: user added marketplace or local plugin 65 */ 66export type TelemetryPluginScope = 67 | 'official' 68 | 'org' 69 | 'user-local' 70 | 'default-bundle' 71 72export function getTelemetryPluginScope( 73 name: string, 74 marketplace: string | undefined, 75 managedNames: Set<string> | null, 76): TelemetryPluginScope { 77 if (marketplace === BUILTIN_MARKETPLACE_NAME) return 'default-bundle' 78 if (isOfficialMarketplaceName(marketplace)) return 'official' 79 if (managedNames?.has(name)) return 'org' 80 return 'user-local' 81} 82 83/** 84 * How a plugin arrived in the session. Splits self-selected from org-pushed 85 * — plugin_scope alone doesn't (an official plugin can be user-installed OR 86 * org-pushed; both are scope='official'). 87 */ 88export type EnabledVia = 89 | 'user-install' 90 | 'org-policy' 91 | 'default-enable' 92 | 'seed-mount' 93 94/** How a skill/command invocation was triggered. */ 95export type InvocationTrigger = 96 | 'user-slash' 97 | 'claude-proactive' 98 | 'nested-skill' 99 100/** Where a skill invocation executes. */ 101export type SkillExecutionContext = 'fork' | 'inline' | 'remote' 102 103/** How a plugin install was initiated. */ 104export type InstallSource = 105 | 'cli-explicit' 106 | 'ui-discover' 107 | 'ui-suggestion' 108 | 'deep-link' 109 110export function getEnabledVia( 111 plugin: LoadedPlugin, 112 managedNames: Set<string> | null, 113 seedDirs: string[], 114): EnabledVia { 115 if (plugin.isBuiltin) return 'default-enable' 116 if (managedNames?.has(plugin.name)) return 'org-policy' 117 // Trailing sep: /opt/plugins must not match /opt/plugins-extra 118 if ( 119 seedDirs.some(dir => 120 plugin.path.startsWith(dir.endsWith(sep) ? dir : dir + sep), 121 ) 122 ) { 123 return 'seed-mount' 124 } 125 return 'user-install' 126} 127 128/** 129 * Common plugin telemetry fields keyed off name@marketplace. Returns the 130 * hash, scope enum, and the redacted-twin columns. Callers add the raw 131 * _PROTO_* fields separately (those require the PII-tagged marker type). 132 */ 133export function buildPluginTelemetryFields( 134 name: string, 135 marketplace: string | undefined, 136 managedNames: Set<string> | null = null, 137): { 138 plugin_id_hash: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 139 plugin_scope: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 140 plugin_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 141 marketplace_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 142 is_official_plugin: boolean 143} { 144 const scope = getTelemetryPluginScope(name, marketplace, managedNames) 145 // Both official marketplaces and builtin plugins are Anthropic-controlled 146 // — safe to expose real names in the redacted columns. 147 const isAnthropicControlled = 148 scope === 'official' || scope === 'default-bundle' 149 return { 150 plugin_id_hash: hashPluginId( 151 name, 152 marketplace, 153 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 154 plugin_scope: 155 scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 156 plugin_name_redacted: (isAnthropicControlled 157 ? name 158 : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 159 marketplace_name_redacted: (isAnthropicControlled && marketplace 160 ? marketplace 161 : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 162 is_official_plugin: isAnthropicControlled, 163 } 164} 165 166/** 167 * Per-invocation callers (SkillTool, processSlashCommand) pass 168 * managedNames=null — the session-level tengu_plugin_enabled_for_session 169 * event carries the authoritative plugin_scope, and per-invocation rows can 170 * join on plugin_id_hash to recover it. This keeps hot-path call sites free 171 * of the extra settings read. 172 */ 173export function buildPluginCommandTelemetryFields( 174 pluginInfo: { pluginManifest: PluginManifest; repository: string }, 175 managedNames: Set<string> | null = null, 176): ReturnType<typeof buildPluginTelemetryFields> { 177 const { marketplace } = parsePluginIdentifier(pluginInfo.repository) 178 return buildPluginTelemetryFields( 179 pluginInfo.pluginManifest.name, 180 marketplace, 181 managedNames, 182 ) 183} 184 185/** 186 * Emit tengu_plugin_enabled_for_session once per enabled plugin at session 187 * start. Supplements tengu_skill_loaded (which still fires per-skill) — use 188 * this for plugin-level aggregates instead of DISTINCT-on-prefix hacks. 189 * A plugin with 5 skills emits 5 skill_loaded rows but 1 of these. 190 */ 191export function logPluginsEnabledForSession( 192 plugins: LoadedPlugin[], 193 managedNames: Set<string> | null, 194 seedDirs: string[], 195): void { 196 for (const plugin of plugins) { 197 const { marketplace } = parsePluginIdentifier(plugin.repository) 198 199 logEvent('tengu_plugin_enabled_for_session', { 200 _PROTO_plugin_name: 201 plugin.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 202 ...(marketplace && { 203 _PROTO_marketplace_name: 204 marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 205 }), 206 ...buildPluginTelemetryFields(plugin.name, marketplace, managedNames), 207 enabled_via: getEnabledVia( 208 plugin, 209 managedNames, 210 seedDirs, 211 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 212 skill_path_count: 213 (plugin.skillsPath ? 1 : 0) + (plugin.skillsPaths?.length ?? 0), 214 command_path_count: 215 (plugin.commandsPath ? 1 : 0) + (plugin.commandsPaths?.length ?? 0), 216 has_mcp: plugin.manifest.mcpServers !== undefined, 217 has_hooks: plugin.hooksConfig !== undefined, 218 ...(plugin.manifest.version && { 219 version: plugin.manifest 220 .version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 221 }), 222 }) 223 } 224} 225 226/** 227 * Bounded-cardinality error bucket for CLI plugin operation failures. 228 * Maps free-form error messages to 5 stable categories so dashboard 229 * GROUP BY stays tractable. 230 */ 231export type PluginCommandErrorCategory = 232 | 'network' 233 | 'not-found' 234 | 'permission' 235 | 'validation' 236 | 'unknown' 237 238export function classifyPluginCommandError( 239 error: unknown, 240): PluginCommandErrorCategory { 241 const msg = String((error as { message?: unknown })?.message ?? error) 242 if ( 243 /ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|network|Could not resolve|Connection refused|timed out/i.test( 244 msg, 245 ) 246 ) { 247 return 'network' 248 } 249 if (/\b404\b|not found|does not exist|no such plugin/i.test(msg)) { 250 return 'not-found' 251 } 252 if (/\b40[13]\b|EACCES|EPERM|permission denied|unauthorized/i.test(msg)) { 253 return 'permission' 254 } 255 if (/invalid|malformed|schema|validation|parse error/i.test(msg)) { 256 return 'validation' 257 } 258 return 'unknown' 259} 260 261/** 262 * Emit tengu_plugin_load_failed once per error surfaced by session-start 263 * plugin loading. Pairs with tengu_plugin_enabled_for_session so dashboards 264 * can compute a load-success rate. PluginError.type is already a bounded 265 * enum — use it directly as error_category. 266 */ 267export function logPluginLoadErrors( 268 errors: PluginError[], 269 managedNames: Set<string> | null, 270): void { 271 for (const err of errors) { 272 const { name, marketplace } = parsePluginIdentifier(err.source) 273 // Not all PluginError variants carry a plugin name (some have pluginId, 274 // some are marketplace-level). Use the 'plugin' property if present, 275 // fall back to the name parsed from err.source. 276 const pluginName = 'plugin' in err && err.plugin ? err.plugin : name 277 logEvent('tengu_plugin_load_failed', { 278 error_category: 279 err.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 280 _PROTO_plugin_name: 281 pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 282 ...(marketplace && { 283 _PROTO_marketplace_name: 284 marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 285 }), 286 ...buildPluginTelemetryFields(pluginName, marketplace, managedNames), 287 }) 288 } 289}