diff --git a/src/utils/capture.ts b/src/utils/capture.ts index 3bff3ea8..6714c5c3 100644 --- a/src/utils/capture.ts +++ b/src/utils/capture.ts @@ -88,227 +88,9 @@ export function sanitizeError(error: any): { message: string, code?: string } { }; } -/** - * Send an event to telemetry - * @param event Event name - * @param properties Optional event properties - */ -// TODO(cleanup): captureBase is now dead code — no caller remains after the GA -// removal (only referenced in a comment). It still carries the full GA4-flavored -// send path. Remove it, or repurpose it as the shared proxy transport. -export const captureBase = async (captureURL: string, event: string, properties?: any) => { - try { - // Env kill-switch takes precedence over config (tests/CI). - if (isTelemetryDisabledByEnv()) { - return; - } - - // Check if telemetry is enabled in config (defaults to true if not set) - const telemetryEnabled = await configManager.getValue('telemetryEnabled'); - - // If telemetry is explicitly disabled or GA credentials are missing, don't send - if (isTelemetryDisabledValue(telemetryEnabled) || !captureURL) { - return; - } - - // Get or create the client ID if not already initialized - if (uniqueUserId === 'unknown') { - uniqueUserId = await configManager.getOrCreateClientId(); - } - - // Get current client information for all events - // For remote calls, attribute to the originating remote client (carried on - // the tool call) instead of the device's local currentClient. - const effectiveClient = - currentCallIsRemote && currentRemoteClient ? currentRemoteClient : currentClient; - let clientContext = {}; - if (effectiveClient) { - clientContext = { - client_name: effectiveClient.name, - client_version: effectiveClient.version, - }; - } - - // Track if user saw onboarding page - const sawOnboardingPage = await configManager.getValue('sawOnboardingPage'); - if (sawOnboardingPage !== undefined) { - clientContext = { ...clientContext, saw_onboarding_page: sawOnboardingPage }; - } - - // Create a deep copy of properties to avoid modifying the original objects - // This ensures we don't alter error objects that are also returned to the AI - let sanitizedProperties; - try { - sanitizedProperties = properties ? JSON.parse(JSON.stringify(properties)) : {}; - } catch (e) { - sanitizedProperties = {} - } - - // Sanitize error objects if present - if (sanitizedProperties.error) { - // Handle different types of error objects - if (typeof sanitizedProperties.error === 'object' && sanitizedProperties.error !== null) { - const sanitized = sanitizeError(sanitizedProperties.error); - sanitizedProperties.error = sanitized.message; - if (sanitized.code) { - sanitizedProperties.errorCode = sanitized.code; - } - } else if (typeof sanitizedProperties.error === 'string') { - sanitizedProperties.error = sanitizeError(sanitizedProperties.error).message; - } - } - - // Remove any properties that might contain paths - const sensitiveKeys = ['path', 'filePath', 'directory', 'file_path', 'sourcePath', 'destinationPath', 'fullPath', 'rootPath']; - for (const key of Object.keys(sanitizedProperties)) { - const lowerKey = key.toLowerCase(); - if (sensitiveKeys.some(sensitiveKey => lowerKey.includes(sensitiveKey)) && - lowerKey !== 'fileextension') { // keep fileExtension as it's safe - delete sanitizedProperties[key]; - } - } - - // Is MCP installed with DXT - let isDXT: string = 'false'; - if (process.env.MCP_DXT) { - isDXT = 'true'; - } - - // Is MCP running in a container - use robust detection - const { getSystemInfo } = await import('./system-info.js'); - const systemInfo = getSystemInfo(); - const isContainer: string = systemInfo.docker.isContainer ? 'true' : 'false'; - const containerType: string = systemInfo.docker.containerType || 'none'; - const orchestrator: string = systemInfo.docker.orchestrator || 'none'; - - // Add container metadata (with privacy considerations) - let containerName: string = 'none'; - let containerImage: string = 'none'; - - if (systemInfo.docker.isContainer && systemInfo.docker.containerEnvironment) { - const env = systemInfo.docker.containerEnvironment; - - // Container name - sanitize to remove potentially sensitive info - if (env.containerName) { - // Keep only alphanumeric chars, dashes, and underscores - // Remove random IDs and UUIDs for privacy - containerName = env.containerName - .replace(/[0-9a-f]{8,}/gi, 'ID') // Replace long hex strings with 'ID' - .replace(/[0-9]{8,}/g, 'ID') // Replace long numeric IDs with 'ID' - .substring(0, 50); // Limit length - } - - // Docker image - sanitize registry info for privacy - if (env.dockerImage) { - // Remove registry URLs and keep just image:tag format - containerImage = env.dockerImage - .replace(/^[^/]+\/[^/]+\//, '') // Remove registry.com/namespace/ prefix - .replace(/^[^/]+\//, '') // Remove simple registry.com/ prefix - .replace(/@sha256:.*$/, '') // Remove digest hashes - .substring(0, 100); // Limit length - } - } - - // Detect if we're running through Smithery at runtime - let runtimeSource: string = 'unknown'; - const processArgs = process.argv.join(' '); - try { - if (processArgs.includes('@smithery/cli') || processArgs.includes('smithery')) { - runtimeSource = 'smithery-runtime'; - } else if (processArgs.includes('npx')) { - runtimeSource = 'npx-runtime'; - } else { - runtimeSource = 'direct-runtime'; - } - } catch (error) { - // Ignore detection errors - } - - // Prepare standard properties - const baseProperties = { - timestamp: new Date().toISOString(), - platform: platform(), - isContainer, - containerType, - orchestrator, - containerName, - containerImage, - runtimeSource, - isDXT, - app_version: VERSION, - engagement_time_msec: "100" - }; - - // Combine with sanitized properties and client context - const eventProperties = { - ...baseProperties, - ...clientContext, - // Attribute events to the remote path when the in-flight tool call - // came from a remote device. Placed before sanitizedProperties so an - // explicit `remote` passed by the caller (e.g. captureRemote) wins. - ...(currentCallIsRemote ? { remote: String(true) } : {}), - ...sanitizedProperties - }; - - // Prepare telemetry payload - const payload = { - client_id: uniqueUserId, - non_personalized_ads: false, - timestamp_micros: Date.now() * 1000, - events: [{ - name: event, - params: eventProperties - }] - }; - - // Send data to telemetry endpoint - const postData = JSON.stringify(payload); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData) - } - }; - - const req = https.request(captureURL, options, (res) => { - // Response handling (optional) - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - const success = res.statusCode === 200 || res.statusCode === 204; - if (!success) { - // Optional debug logging - // console.debug(`Telemetry tracking error: ${res.statusCode} ${data}`); - } - }); - }); - - req.on('error', (error) => { - // Silently fail - we don't want analytics issues to break functionality - }); - - // Set timeout to prevent blocking the app - req.setTimeout(3000, () => { - req.destroy(); - }); - - // Send data - req.write(postData); - req.end(); - - } catch (error) { - // Silently fail - we don't want analytics issues to break functionality - } -}; - /** * Build the standard event properties used by the telemetry proxy. - * Extracted from captureBase so both paths get identical data. + * Shared property builder for the live telemetry proxy path. */ const buildEventProperties = async (properties?: any) => { if (uniqueUserId === 'unknown') { @@ -474,18 +256,38 @@ const postTelemetryPayload = async (endpoint: string, payload: string): Promise< }); }; -// TODO(behavior): capture() is now fire-and-forget — every `await capture(...)` -// call site resolves before the network send completes. Fine for the long-running -// MCP server, but events fired right before process exit (e.g. opt-out, feedback) -// can be silently dropped. If we need delivery guarantees on short-lived paths, -// expose an awaitable variant or flush-before-exit hook. +// capture() stays fire-and-forget during normal operation; pending sends are +// tracked so shutdown paths can drain them without blocking indefinitely. +const pendingCaptures = new Set>(); + +export async function flushTelemetry(timeoutMs = 2000): Promise { + if (pendingCaptures.size === 0) return; + + let timeoutHandle: NodeJS.Timeout | undefined; + try { + await Promise.race([ + Promise.allSettled([...pendingCaptures]), + new Promise(resolve => { + timeoutHandle = setTimeout(resolve, timeoutMs); + timeoutHandle.unref?.(); + }) + ]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +} + +process.once('beforeExit', () => { + void flushTelemetry(); +}); + export const capture = async (event: string, properties?: any) => { // Tool calls fired programmatically by the widget UIs must produce zero // telemetry — drop every event raised while serving one. if (isInsideUiOriginCall()) { return; } - void (async () => { + const pending = (async () => { try { const eventProperties = await buildEventProperties(properties); await sendToTelemetryProxy(event, eventProperties); @@ -493,6 +295,8 @@ export const capture = async (event: string, properties?: any) => { // Silent fail — telemetry should never break functionality } })(); + pendingCaptures.add(pending); + void pending.finally(() => pendingCaptures.delete(pending)); } export const capture_call_tool = capture;