Files
openclaude/src/utils/telemetry/pluginTelemetry.ts

170 lines
5.7 KiB
TypeScript

/**
* Legacy plugin metadata helpers shared by call sites that still assemble
* analytics-compatible payload shapes.
*
* In this fork the downstream analytics sinks are disabled, so these helpers
* only normalize/redact fields for local compatibility code; they do not
* imply an active telemetry export path.
*/
import { createHash } from 'crypto'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} from '../../services/analytics/index.js'
import type { 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. Kept stable so legacy field shapes that still
// use this helper continue to derive the same opaque key.
const PLUGIN_ID_HASH_SALT = 'claude-plugin-telemetry-v1'
/**
* Opaque per-plugin compatibility key derived from the name@marketplace
* string. The 16-char truncation keeps the identifier short while preserving
* a stable grouping key for local compatibility code.
*/
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 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'
/**
* Common plugin metadata fields keyed off name@marketplace. Keeps the legacy
* field set in one place so no-op analytics compatibility callers do not have
* to duplicate redaction logic.
*/
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 to keep 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,
)
}
/**
* Stable error buckets for CLI plugin operation failures.
*/
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'
}