/ src / utils / telemetry / pluginTelemetry.ts
pluginTelemetry.ts
  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  
 14  import { createHash } from 'crypto'
 15  import { sep } from 'path'
 16  import {
 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'
 21  import type {
 22    LoadedPlugin,
 23    PluginError,
 24    PluginManifest,
 25  } from '../../types/plugin.js'
 26  import {
 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.
 33  const 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.
 39  const 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   */
 48  export 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   */
 66  export type TelemetryPluginScope =
 67    | 'official'
 68    | 'org'
 69    | 'user-local'
 70    | 'default-bundle'
 71  
 72  export 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   */
 88  export type EnabledVia =
 89    | 'user-install'
 90    | 'org-policy'
 91    | 'default-enable'
 92    | 'seed-mount'
 93  
 94  /** How a skill/command invocation was triggered. */
 95  export type InvocationTrigger =
 96    | 'user-slash'
 97    | 'claude-proactive'
 98    | 'nested-skill'
 99  
100  /** Where a skill invocation executes. */
101  export type SkillExecutionContext = 'fork' | 'inline' | 'remote'
102  
103  /** How a plugin install was initiated. */
104  export type InstallSource =
105    | 'cli-explicit'
106    | 'ui-discover'
107    | 'ui-suggestion'
108    | 'deep-link'
109  
110  export 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   */
133  export 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   */
173  export 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   */
191  export 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   */
231  export type PluginCommandErrorCategory =
232    | 'network'
233    | 'not-found'
234    | 'permission'
235    | 'validation'
236    | 'unknown'
237  
238  export 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   */
267  export 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  }