/** * Telemetry Submission Service * Handles sending telemetry data to remote gateway endpoint */ import { z } from "zod"; import { getConfig } from "./config-manager.js"; import { getTelemetryEnabled } from "./config-manager.js"; import { resolveEnvVariable } from "./utils.js"; // Telemetry data validation schema const TelemetryDataSchema = z.object({ timestamp: z.string().datetime(), userId: z.string().min(1), commandName: z.string().min(1), modelUsed: z.string().optional(), providerName: z.string().optional(), inputTokens: z.number().optional(), outputTokens: z.number().optional(), totalTokens: z.number().optional(), totalCost: z.number().optional(), currency: z.string().optional(), commandArgs: z.any().optional(), fullOutput: z.any().optional(), }); // Hardcoded configuration for TaskMaster telemetry gateway const TASKMASTER_BASE_URL = "http://localhost:4444"; const TASKMASTER_TELEMETRY_ENDPOINT = `${TASKMASTER_BASE_URL}/api/v1/telemetry`; const TASKMASTER_USER_REGISTRATION_ENDPOINT = `${TASKMASTER_BASE_URL}/auth/init`; const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second /** * Get telemetry configuration from environment variables only * @returns {Object} Configuration object with apiKey, userId, and email */ function getTelemetryConfig() { // Try environment variables first (includes .env file via resolveEnvVariable) const envApiKey = resolveEnvVariable("TASKMASTER_API_KEY") || resolveEnvVariable("GATEWAY_API_KEY") || resolveEnvVariable("TELEMETRY_API_KEY"); const envUserId = resolveEnvVariable("TASKMASTER_USER_ID") || resolveEnvVariable("GATEWAY_USER_ID") || resolveEnvVariable("TELEMETRY_USER_ID"); const envEmail = resolveEnvVariable("TASKMASTER_USER_EMAIL") || resolveEnvVariable("GATEWAY_USER_EMAIL") || resolveEnvVariable("TELEMETRY_USER_EMAIL"); // Get the config (which might contain userId) const config = getConfig(); return { apiKey: envApiKey || null, // API key should only come from environment userId: envUserId || config?.account?.userId || null, email: envEmail || null, }; } /** * Register or lookup user with the TaskMaster telemetry gateway using /auth/init * @param {string} email - User's email address * @param {string} userId - User's ID * @returns {Promise<{success: boolean, apiKey?: string, userId?: string, email?: string, isNewUser?: boolean, error?: string}>} */ export async function registerUserWithGateway(email = null, userId = null) { try { const requestBody = {}; if (email) requestBody.email = email; if (userId) requestBody.userId = userId; const response = await fetch(TASKMASTER_USER_REGISTRATION_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); if (!response.ok) { return { success: false, error: `Gateway registration failed: ${response.status} ${response.statusText}`, }; } const result = await response.json(); // Handle the /auth/init response format if (result.success && result.data) { return { success: true, apiKey: result.data.token, userId: result.data.userId, email: email, isNewUser: result.data.isNewUser, }; } else { return { success: false, error: result.error || result.message || "Unknown registration error", }; } } catch (error) { return { success: false, error: `Gateway registration error: ${error.message}`, }; } } /** * Submits telemetry data to the remote gateway endpoint * @param {Object} telemetryData - The telemetry data to submit * @returns {Promise} - Result object with success status and details */ export async function submitTelemetryData(telemetryData) { try { // Check user opt-out preferences first if (!getTelemetryEnabled()) { return { success: true, skipped: true, reason: "Telemetry disabled by user preference", }; } // Get telemetry configuration const telemetryConfig = getTelemetryConfig(); if ( !telemetryConfig.apiKey || !telemetryConfig.userId || !telemetryConfig.email ) { return { success: false, error: "Telemetry configuration incomplete. Run 'task-master init' and select hosted gateway option, or manually set TASKMASTER_API_KEY, TASKMASTER_USER_ID, and TASKMASTER_USER_EMAIL environment variables", }; } // Validate telemetry data try { TelemetryDataSchema.parse(telemetryData); } catch (validationError) { return { success: false, error: `Telemetry data validation failed: ${validationError.message}`, }; } // Send FULL telemetry data to gateway (including commandArgs and fullOutput) // Note: Sensitive data filtering is handled separately for user-facing responses const completeTelemetryData = { ...telemetryData, userId: telemetryConfig.userId, // Ensure correct userId }; // Attempt submission with retry logic let lastError; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const response = await fetch(TASKMASTER_TELEMETRY_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${telemetryConfig.apiKey}`, // Use Bearer token format "X-User-Email": telemetryConfig.email, // Add required email header }, body: JSON.stringify(completeTelemetryData), }); if (response.ok) { const result = await response.json(); return { success: true, id: result.id, attempt, }; } else { // Handle HTTP error responses const errorData = await response.json().catch(() => ({})); const errorMessage = `HTTP ${response.status} ${response.statusText}`; // Don't retry on certain status codes (rate limiting, auth errors, etc.) if ( response.status === 429 || response.status === 401 || response.status === 403 ) { return { success: false, error: errorMessage, statusCode: response.status, }; } // For other HTTP errors, continue retrying lastError = new Error(errorMessage); } } catch (networkError) { lastError = networkError; } // Wait before retry (exponential backoff) if (attempt < MAX_RETRIES) { await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY * Math.pow(2, attempt - 1)) ); } } // All retries failed return { success: false, error: lastError.message, attempts: MAX_RETRIES, }; } catch (error) { // Graceful error handling - never throw return { success: false, error: `Telemetry submission failed: ${error.message}`, }; } } /** * Submits telemetry data asynchronously without blocking execution * @param {Object} telemetryData - The telemetry data to submit */ export function submitTelemetryDataAsync(telemetryData) { // Fire and forget - don't block execution submitTelemetryData(telemetryData).catch((error) => { // Silently log errors without blocking console.debug("Telemetry submission failed:", error); }); }