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