@extenshi/mcp
Version:
Extenshi MCP server — bring the extension catalog (search, security, market research) and pre-publish scanning into your AI tools
341 lines • 13.2 kB
JavaScript
/**
* Anonymous, opt-out usage telemetry — VENDORED, kept byte-identical between
* @extenshi/cli and @extenshi/mcp (same pattern as validate-artifact.ts). Edit
* one, copy to the other.
*
* Why it exists: we want to know which commands/tools are used most, which fail,
* and what errors recur — without shipping a heavyweight client or leaking PII.
*
* What it sends (and ONLY this):
* - command/tool name, surface ('cli' | 'mcp'), package + Node version,
* coarse os/arch, a CI bool
* - the NAMES of CLI flags used (never their values — see flagsFromArgv)
* - a coarse error_kind classification + durations
* - a path-redacted exception signature (message + stack, $HOME stripped)
*
* What it NEVER sends: file paths, artifact names/contents, API keys, manifest
* data, or raw user input.
*
* Identity is an anonymous per-install UUID in ~/.extenshi/config.json
* (`anonId`) — it identifies an install, not a person, and is never linked to
* the API key client-side.
*
* On by default: the shared-project ingestion key is embedded (see
* EMBEDDED_KEY), so telemetry is active unless the user opts out. Disabled
* (no-op, never blocks the command) when ANY holds:
* - DO_NOT_TRACK is truthy (industry standard, https://consoledonottrack.com)
* - EXTENSHI_TELEMETRY in {0,false,off,no}
* - config file has "telemetry": false
* - the key is cleared (EMBEDDED_KEY emptied AND no EXTENSHI_POSTHOG_KEY)
*
* Everything here is fail-soft: any throw inside telemetry is swallowed so a
* PostHog/network/disk problem can never break or slow the actual command.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PostHog } from 'posthog-node';
// Shared PostHog *project* ingestion key (EU project 114791 — the same project
// the web apps use; we run on the free tier so CLI/MCP share it). It is the
// public, write-only key already shipped in our web bundles, so embedding it
// here is not a secret leak. CLI/MCP events are namespaced (`cli_*` / `mcp_*`,
// plus a `surface` property) so they stay separable from web analytics.
// Override at runtime with EXTENSHI_POSTHOG_KEY; set it empty to ship dark.
const EMBEDDED_KEY = 'phc_fqKrAmtNZvJqe0krpYB3UwYqALLpT1WM8m5LtNs9eUu';
const DEFAULT_HOST = 'https://eu.i.posthog.com';
const CONFIG_PATH = path.join(os.homedir(), '.extenshi', 'config.json');
const HOME = os.homedir();
let surface = 'unknown';
let appVersion = '0.0.0';
let client = null;
let triedInit = false;
let cachedAnonId = null;
/** Record the surface + version once at process startup. */
export function initTelemetry(opts) {
surface = opts.surface;
appVersion = opts.version;
}
function telemetryKey() {
return (process.env.EXTENSHI_POSTHOG_KEY ?? EMBEDDED_KEY).trim();
}
function telemetryHost() {
return (process.env.EXTENSHI_POSTHOG_HOST ?? DEFAULT_HOST).trim();
}
function readConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
catch {
return {};
}
}
/** Merge-write so we never clobber apiKey/apiUrl written by `extenshi login`. */
function mergeWriteConfig(update) {
try {
const existing = readConfig();
const merged = { ...existing, ...update };
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
}
catch {
// Read-only home / unwritable config — fall back to an ephemeral id.
}
}
function isTruthy(v) {
const t = (v ?? '').trim().toLowerCase();
return t === '1' || t === 'true' || t === 'yes' || t === 'on';
}
export function telemetryEnabled() {
if (!telemetryKey())
return false;
if (isTruthy(process.env.DO_NOT_TRACK))
return false;
const t = (process.env.EXTENSHI_TELEMETRY ?? '').trim().toLowerCase();
if (t === '0' || t === 'false' || t === 'off' || t === 'no')
return false;
if (readConfig().telemetry === false)
return false;
return true;
}
function anonId() {
if (cachedAnonId)
return cachedAnonId;
const existing = readConfig().anonId;
if (existing) {
cachedAnonId = existing;
return existing;
}
const id = randomUUID();
cachedAnonId = id;
mergeWriteConfig({ anonId: id });
return id;
}
function getClient() {
// Fast path after the first call: never re-read the config file
// (telemetryEnabled does a sync read) on subsequent events — matters for the
// long-running MCP server, which can handle many tool calls.
if (client)
return client;
if (triedInit)
return null;
triedInit = true;
if (!telemetryEnabled())
return null;
try {
// Batch events; the 2–3 events a CLI run emits flush together on
// flushTelemetry()/shutdown(). The MCP server (long-running) also gets a
// periodic interval flush.
client = new PostHog(telemetryKey(), { host: telemetryHost(), flushAt: 20, flushInterval: 5000 });
}
catch {
client = null;
}
return client;
}
/**
* The shared posthog-node client, or null when telemetry is disabled. Exposed so
* the MCP package's PostHog MCP Analytics instrumentation (mcp-analytics.ts)
* rides this same client: one opt-out, one flush path (flushTelemetry).
*/
export function telemetryClient() {
return getClient();
}
function baseProps() {
return {
surface,
version: appVersion,
node: process.version,
os: process.platform,
arch: process.arch,
ci: Boolean(process.env.CI),
};
}
/** Strip absolute home paths from any string before it leaves the machine. */
export function redactText(s) {
if (!s)
return s;
let out = s;
if (HOME)
out = out.split(HOME).join('~');
out = out.replace(/\/(?:Users|home)\/[^/\s]+/g, '~');
out = out.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, '~');
return out;
}
function sanitizeError(err) {
const original = err instanceof Error ? err : new Error(String(err));
const e = new Error(redactText(original.message));
e.name = original.name;
if (original.stack)
e.stack = redactText(original.stack);
return e;
}
/**
* The HTTP status an error carries, whichever shape it arrived in:
* - `status` — the MCP package's ScanError (@extenshi/mcp scan.ts).
* - `data.httpStatus` — @trpc/client's TRPCClientError, i.e. every catalog BFF
* read on EITHER surface. tRPC puts the status inside `data` and has NO
* top-level `status`, so reading only `status` left the entire read path to
* message heuristics.
*
* Surface-neutral on purpose: this file is vendored byte-identical into both
* packages (see the header), so it must describe both. Neither branch costs the
* surface that cannot produce that shape anything — the check simply never fires.
*/
function statusOf(err) {
const e = err;
if (typeof e?.status === 'number')
return e.status;
if (typeof e?.data?.httpStatus === 'number')
return e.data.httpStatus;
return undefined;
}
/** Classify a single error, ignoring any `cause` chain. See classifyError(). */
function classifyOne(err) {
const status = statusOf(err);
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
// InvalidInputError (expected-error.ts): the user's own file or flag.
if (err instanceof Error && err.name === 'InvalidInputError')
return 'invalid_input';
if (status === 401 || status === 403)
return 'auth';
if (status === 402)
return 'quota';
if (status === 429)
return 'rate_limit';
if (status && status >= 500)
return 'api_5xx';
if (status && status >= 400)
return 'api_4xx';
if (/enotfound|econnrefused|econnreset|eai_again|fetch failed|network|socket hang|und_err/.test(msg))
return 'network';
if (/timed out|timeout|aborted|aborterror/.test(msg))
return 'timeout';
if (/authentication failed|unauthorized|api key|access denied|forbidden/.test(msg))
return 'auth';
// `allowance exhausted` is the catalog BFF's free-read gate (routers/_app.ts:
// "Free read allowance exhausted (25/mo)"), which matches none of the credit
// wordings above.
if (/insufficient credits|out of .*credits|\bquota\b|allowance exhausted/.test(msg))
return 'quota';
if (/rate limit/.test(msg))
return 'rate_limit';
if (/artifact|not a file|file not found|exceeds .*mb|invalid .*(crx|xpi|zip)|magic|unsupported/.test(msg))
return 'invalid_artifact';
if (/http 4\d{2}/.test(msg))
return 'api_4xx';
if (/non-json response|http 5\d{2}|server returned/.test(msg))
return 'api_5xx';
return 'unexpected';
}
/** How deep to follow `cause` before giving up (guards a cyclic chain). */
const MAX_CAUSE_DEPTH = 4;
/**
* Coarse error bucket. Driven by a numeric status when the error carries one
* (ScanError `status`, tRPC `data.httpStatus`), else by message patterns (both
* surfaces also throw plain Errors). Order matters — status first, then
* transport, then semantic buckets.
*
* Follows the `cause` chain, because both surfaces re-throw a failure wrapped in
* an error of their own so it renders for the caller — the MCP tool layer wraps
* in fastmcp's `UserError` (readError() in its tools.ts) — and the wrapper's own
* message can carry no status. The origin hangs off `cause`, so an
* unclassifiable wrapper defers to what it wraps; without this, a BFF 500 behind
* a wrapper reads as `unexpected`.
*/
export function classifyError(err) {
let cur = err;
for (let depth = 0; cur != null && depth < MAX_CAUSE_DEPTH; depth++) {
const kind = classifyOne(cur);
if (kind !== 'unexpected')
return kind;
const next = cur.cause;
// Handles the DIRECT cycle only (`a.cause === a`): such an error has no
// origin left to consult, which is a different thing from running out of
// depth budget, so stopping here says so at the point it's true. A longer
// cycle (A → B → A) is not detected and is not meant to be — it terminates
// on MAX_CAUSE_DEPTH like any other over-deep chain, which is why the
// bound, not this check, is what guarantees termination.
if (next === cur)
break;
cur = next;
}
return 'unexpected';
}
/**
* The NAMES of long flags the user passed, with values stripped. `--out=/x`
* becomes `--out`; the separate-token value of `--output /x` is never a `--`
* token, so it is never collected. No flag VALUE can leak through here.
*/
export function flagsFromArgv(argv = process.argv.slice(2)) {
const flags = new Set();
for (const tok of argv) {
if (tok.startsWith('--'))
flags.add(tok.split('=')[0]);
}
return [...flags];
}
/**
* `distinctId` overrides the per-install anonymous id. The CLI never passes it —
* one install IS one identity there. The hosted MCP connector must: it is one
* process serving many accounts, so the install id would collapse every user of
* the service into a single person and re-mint on each redeploy. It passes the
* authenticated account id instead (never an email — that stays in our own
* database, see tools/extenshi-mcp-server/src/legal.ts).
*/
export function captureEvent(event, properties = {}, distinctId) {
const c = getClient();
if (!c)
return;
// Empty is treated as ABSENT, not as an id. PostHog accepts "" and mints a
// person nobody can resolve, so a caller bug would turn into permanently
// unattributable rows; degrading to the install id keeps the event readable.
// attribution() already filters empties — this restates the contract at the
// sink so it does not depend on the caller doing so.
const id = distinctId?.trim() || anonId();
try {
c.capture({
distinctId: id,
event,
properties: { ...baseProps(), ...properties },
});
}
catch {
// Telemetry must never throw into the command path.
}
}
export function captureError(err, context = {}) {
const c = getClient();
if (!c)
return;
try {
c.captureException(sanitizeError(err), anonId(), { ...baseProps(), ...context });
}
catch {
// Swallow — see captureEvent.
}
}
function timeout(ms) {
return new Promise((resolve) => {
// unref so the flush timer can't keep a short-lived CLI process alive.
setTimeout(resolve, ms).unref();
});
}
/**
* Flush buffered events and close the client. Hard-capped so a slow/hung
* PostHog endpoint can never delay the command's exit. Safe to call when
* telemetry is disabled (no-op) or more than once.
*/
export async function flushTelemetry() {
if (!client)
return;
const c = client;
client = null;
try {
await Promise.race([c.shutdown(2000), timeout(2500)]);
}
catch {
// Best-effort.
}
}
//# sourceMappingURL=telemetry.js.map