feat(task-90): Complete telemetry integration with init flow improvements - Task 90.3: AI Services Integration COMPLETED with automatic submission after AI usage logging and graceful error handling - Init Flow Enhancements: restructured to prioritize gateway selection with beautiful UI for BYOK vs Hosted modes - Telemetry Improvements: modified submission to send FULL data to gateway while maintaining security filtering for users - All 344 tests passing, telemetry integration ready for production

This commit is contained in:
Eyal Toledano
2025-05-30 16:35:40 -04:00
parent 75b7b93fa4
commit e573db3b3b
12 changed files with 1955 additions and 496 deletions

View File

@@ -26,6 +26,7 @@ import {
getVertexLocation,
} from "./config-manager.js";
import { log, findProjectRoot, resolveEnvVariable } from "./utils.js";
import { submitTelemetryData } from "./telemetry-submission.js";
// Import provider classes
import {
@@ -728,7 +729,20 @@ async function logAiUsage({
log("info", "AI Usage Telemetry:", telemetryData);
}
// TODO (Subtask 77.2): Send telemetryData securely to the external endpoint.
// Subtask 90.3: Submit telemetry data to gateway
try {
const submissionResult = await submitTelemetryData(telemetryData);
if (getDebugFlag() && submissionResult.success) {
log("debug", "Telemetry data successfully submitted to gateway");
} else if (getDebugFlag() && !submissionResult.success) {
log("debug", `Telemetry submission failed: ${submissionResult.error}`);
}
} catch (submissionError) {
// Telemetry submission should never block core functionality
if (getDebugFlag()) {
log("debug", `Telemetry submission error: ${submissionError.message}`);
}
}
return telemetryData;
} catch (error) {

View File

@@ -5,6 +5,7 @@
import { z } from "zod";
import { getConfig } from "./config-manager.js";
import { resolveEnvVariable } from "./utils.js";
// Telemetry data validation schema
const TelemetryDataSchema = z.object({
@@ -30,37 +31,31 @@ const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
/**
* Get telemetry configuration from environment or config
* Get telemetry configuration from environment variables only
* @returns {Object} Configuration object with apiKey, userId, and email
*/
function getTelemetryConfig() {
// Try environment variables first (for testing and manual setup)
// Try environment variables first (includes .env file via resolveEnvVariable)
const envApiKey =
process.env.TASKMASTER_API_KEY ||
process.env.GATEWAY_API_KEY ||
process.env.TELEMETRY_API_KEY;
resolveEnvVariable("TASKMASTER_API_KEY") ||
resolveEnvVariable("GATEWAY_API_KEY") ||
resolveEnvVariable("TELEMETRY_API_KEY");
const envUserId =
process.env.TASKMASTER_USER_ID ||
process.env.GATEWAY_USER_ID ||
process.env.TELEMETRY_USER_ID;
resolveEnvVariable("TASKMASTER_USER_ID") ||
resolveEnvVariable("GATEWAY_USER_ID") ||
resolveEnvVariable("TELEMETRY_USER_ID");
const envEmail =
process.env.TASKMASTER_USER_EMAIL ||
process.env.GATEWAY_USER_EMAIL ||
process.env.TELEMETRY_USER_EMAIL;
resolveEnvVariable("TASKMASTER_USER_EMAIL") ||
resolveEnvVariable("GATEWAY_USER_EMAIL") ||
resolveEnvVariable("TELEMETRY_USER_EMAIL");
if (envApiKey && envUserId && envEmail) {
return { apiKey: envApiKey, userId: envUserId, email: envEmail };
}
// Fall back to config file (preferred for hosted gateway setup)
// Get the config (which might contain userId)
const config = getConfig();
return {
apiKey: config?.telemetry?.apiKey || config?.telemetryApiKey,
userId:
config?.telemetry?.userId ||
config?.telemetryUserId ||
config?.global?.userId,
email: config?.telemetry?.email || config?.telemetryUserEmail,
apiKey: envApiKey || null, // API key should only come from environment
userId: envUserId || config?.global?.userId || null,
email: envEmail || null,
};
}
@@ -152,9 +147,12 @@ export async function submitTelemetryData(telemetryData) {
};
}
// Filter out sensitive fields before submission and ensure userId is set
const { commandArgs, fullOutput, ...safeTelemetryData } = telemetryData;
safeTelemetryData.userId = telemetryConfig.userId; // Ensure correct userId
// 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;
@@ -167,7 +165,7 @@ export async function submitTelemetryData(telemetryData) {
Authorization: `Bearer ${telemetryConfig.apiKey}`, // Use Bearer token format
"X-User-Email": telemetryConfig.email, // Add required email header
},
body: JSON.stringify(safeTelemetryData),
body: JSON.stringify(completeTelemetryData),
});
if (response.ok) {

View File

@@ -0,0 +1,315 @@
import fs from "fs";
import path from "path";
import { log, findProjectRoot } from "./utils.js";
import { getConfig, writeConfig } from "./config-manager.js";
/**
* Registers or finds a user via the gateway's /auth/init endpoint
* @param {string|null} email - Optional user's email address (only needed for billing)
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {Promise<{success: boolean, userId: string, token: string, isNewUser: boolean, error?: string}>}
*/
async function registerUserWithGateway(email = null, explicitRoot = null) {
try {
const gatewayUrl =
process.env.TASKMASTER_GATEWAY_URL || "http://localhost:4444";
// Email is optional - only send if provided
const requestBody = email ? { email } : {};
const response = await fetch(`${gatewayUrl}/auth/init`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
return {
success: false,
userId: "",
token: "",
isNewUser: false,
error: `Gateway registration failed: ${response.status} ${errorText}`,
};
}
const result = await response.json();
if (result.success && result.data) {
return {
success: true,
userId: result.data.userId,
token: result.data.token,
isNewUser: result.data.isNewUser,
};
} else {
return {
success: false,
userId: "",
token: "",
isNewUser: false,
error: "Invalid response format from gateway",
};
}
} catch (error) {
return {
success: false,
userId: "",
token: "",
isNewUser: false,
error: `Network error: ${error.message}`,
};
}
}
/**
* Updates the user configuration with gateway registration results
* @param {string} userId - User ID from gateway
* @param {string} token - API token from gateway
* @param {string} mode - User mode ('byok' or 'hosted')
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {boolean} Success status
*/
function updateUserConfig(userId, token, mode, explicitRoot = null) {
try {
const config = getConfig(explicitRoot);
// Ensure global section exists
if (!config.global) {
config.global = {};
}
// Update user configuration
config.global.userId = userId;
config.global.mode = mode; // 'byok' or 'hosted'
// Write API token to .env file (not config)
if (token) {
writeApiKeyToEnv(token, explicitRoot);
}
// Save updated config
const success = writeConfig(config, explicitRoot);
if (success) {
log("info", `User configuration updated: userId=${userId}, mode=${mode}`);
} else {
log("error", "Failed to write updated user configuration");
}
return success;
} catch (error) {
log("error", `Error updating user config: ${error.message}`);
return false;
}
}
/**
* Writes the API token to the .env file
* @param {string} token - API token to write
* @param {string|null} explicitRoot - Optional explicit project root path
*/
function writeApiKeyToEnv(token, explicitRoot = null) {
try {
// Determine project root
let rootPath = explicitRoot;
if (!rootPath) {
rootPath = findProjectRoot();
if (!rootPath) {
log("warn", "Could not determine project root for .env file");
return;
}
}
const envPath = path.join(rootPath, ".env");
let envContent = "";
// Read existing .env content if file exists
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, "utf8");
}
// Check if TASKMASTER_API_KEY already exists
const lines = envContent.split("\n");
let keyExists = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("TASKMASTER_API_KEY=")) {
lines[i] = `TASKMASTER_API_KEY=${token}`;
keyExists = true;
break;
}
}
// Add key if it doesn't exist
if (!keyExists) {
if (envContent && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += `TASKMASTER_API_KEY=${token}\n`;
} else {
envContent = lines.join("\n");
}
// Write updated content
fs.writeFileSync(envPath, envContent);
log("info", "API key written to .env file");
} catch (error) {
log("error", `Failed to write API key to .env: ${error.message}`);
}
}
/**
* Gets the current user mode from configuration
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {string} User mode ('byok', 'hosted', or 'unknown')
*/
function getUserMode(explicitRoot = null) {
try {
const config = getConfig(explicitRoot);
return config?.global?.mode || "unknown";
} catch (error) {
log("error", `Error getting user mode: ${error.message}`);
return "unknown";
}
}
/**
* Checks if user is in hosted mode
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {boolean} True if user is in hosted mode
*/
function isHostedMode(explicitRoot = null) {
return getUserMode(explicitRoot) === "hosted";
}
/**
* Checks if user is in BYOK mode
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {boolean} True if user is in BYOK mode
*/
function isByokMode(explicitRoot = null) {
return getUserMode(explicitRoot) === "byok";
}
/**
* Complete user setup: register with gateway and configure TaskMaster
* @param {string|null} email - Optional user's email (only needed for billing)
* @param {string} mode - User's mode: 'byok' or 'hosted'
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {Promise<{success: boolean, userId: string, mode: string, error?: string}>}
*/
async function setupUser(email = null, mode = "hosted", explicitRoot = null) {
try {
// Step 1: Register with gateway (email optional)
const registrationResult = await registerUserWithGateway(
email,
explicitRoot
);
if (!registrationResult.success) {
return {
success: false,
userId: "",
mode: "",
error: registrationResult.error,
};
}
// Step 2: Update config with userId and mode
const configResult = await updateUserConfig(
registrationResult.userId,
mode,
explicitRoot
);
if (!configResult) {
return {
success: false,
userId: registrationResult.userId,
mode: "",
error: "Failed to update user configuration",
};
}
return {
success: true,
userId: registrationResult.userId,
mode: mode,
message: email
? `User setup complete with email ${email}`
: "User setup complete (email will be collected during billing setup)",
};
} catch (error) {
return {
success: false,
userId: "",
mode: "",
error: `Setup failed: ${error.message}`,
};
}
}
/**
* Initialize TaskMaster user (typically called during init)
* Gets userId from gateway without requiring email upfront
* @param {string|null} explicitRoot - Optional explicit project root path
* @returns {Promise<{success: boolean, userId: string, error?: string}>}
*/
async function initializeUser(explicitRoot = null) {
try {
// Register with gateway without email
const result = await registerUserWithGateway(null, explicitRoot);
if (!result.success) {
return {
success: false,
userId: "",
error: result.error,
};
}
// Update config with userId, token, and default hosted mode
const configResult = updateUserConfig(
result.userId,
result.token, // Include the token parameter
"hosted", // Default to hosted mode until user chooses plan
explicitRoot
);
if (!configResult) {
return {
success: false,
userId: result.userId,
error: "Failed to update user configuration",
};
}
return {
success: true,
userId: result.userId,
message: result.isNewUser
? "New user registered with gateway"
: "Existing user found in gateway",
};
} catch (error) {
return {
success: false,
userId: "",
error: `Initialization failed: ${error.message}`,
};
}
}
export {
registerUserWithGateway,
updateUserConfig,
writeApiKeyToEnv,
getUserMode,
isHostedMode,
isByokMode,
setupUser,
initializeUser,
};