chore: initialize recovered claude workspace
This commit is contained in:
289
src/utils/telemetry/pluginTelemetry.ts
Normal file
289
src/utils/telemetry/pluginTelemetry.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user