- Implement secure telemetry submission service - Created scripts/modules/telemetry-submission.js with submitTelemetryData function - Implemented secure filtering: removes commandArgs and fullOutput before remote submission - Added comprehensive validation using Zod schema for telemetry data integrity - Implemented exponential backoff retry logic (3 attempts max) with smart retry decisions - Added graceful error handling that never blocks execution - Respects user opt-out preferences via config.telemetryEnabled - Configured for localhost testing endpoint (http://localhost:4444/api/v1/telemetry) for now - Added comprehensive test coverage with 6/6 passing tests covering all scenarios - Includes submitTelemetryDataAsync for fire-and-forget submissions
214 lines
6.6 KiB
JavaScript
214 lines
6.6 KiB
JavaScript
/**
|
|
* Tests for telemetry submission service (Task 90.2)
|
|
* Testing remote endpoint submission with retry logic and error handling
|
|
*/
|
|
|
|
import { jest } from "@jest/globals";
|
|
import { z } from "zod";
|
|
|
|
// Mock fetch for testing HTTP requests
|
|
global.fetch = jest.fn();
|
|
|
|
// Mock config-manager
|
|
const mockGetConfig = jest.fn();
|
|
jest.unstable_mockModule(
|
|
"../../../../scripts/modules/config-manager.js",
|
|
() => ({
|
|
__esModule: true,
|
|
getConfig: mockGetConfig,
|
|
})
|
|
);
|
|
|
|
describe("Telemetry Submission Service - Task 90.2", () => {
|
|
let telemetrySubmission;
|
|
|
|
beforeAll(async () => {
|
|
// Import after mocking
|
|
telemetrySubmission = await import(
|
|
"../../../../scripts/modules/telemetry-submission.js"
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
// Reset fetch mock
|
|
fetch.mockClear();
|
|
mockGetConfig.mockClear();
|
|
|
|
// Default config mock - telemetry enabled
|
|
mockGetConfig.mockReturnValue({ telemetryEnabled: true });
|
|
});
|
|
|
|
describe("Subtask 90.2: Send telemetry data to remote database endpoint", () => {
|
|
it("should successfully submit telemetry data to gateway endpoint", async () => {
|
|
// Mock successful response
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ success: true, id: "telemetry-123" }),
|
|
});
|
|
|
|
const telemetryData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "add-task",
|
|
modelUsed: "claude-3-sonnet",
|
|
providerName: "anthropic",
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
totalCost: 0.001,
|
|
currency: "USD",
|
|
// These sensitive fields should be filtered out before submission
|
|
commandArgs: { id: "15", prompt: "Test task" },
|
|
fullOutput: { title: "Generated Task", description: "AI output" },
|
|
};
|
|
|
|
// Expected data after filtering (without commandArgs and fullOutput)
|
|
const expectedFilteredData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "add-task",
|
|
modelUsed: "claude-3-sonnet",
|
|
providerName: "anthropic",
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
totalTokens: 150,
|
|
totalCost: 0.001,
|
|
currency: "USD",
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(telemetryData);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.id).toBe("telemetry-123");
|
|
|
|
// Verify the request was made with filtered data (security requirement)
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://gateway.task-master.dev/telemetry",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(expectedFilteredData),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should implement retry logic for failed requests", async () => {
|
|
// Mock first two calls to fail, third to succeed
|
|
fetch
|
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ success: true, id: "telemetry-retry-123" }),
|
|
});
|
|
|
|
const telemetryData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "expand-task",
|
|
modelUsed: "claude-3-sonnet",
|
|
totalCost: 0.002,
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(telemetryData);
|
|
|
|
// Verify retry attempts (should be called 3 times)
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
expect(result.success).toBe(true);
|
|
expect(result.id).toBe("telemetry-retry-123");
|
|
});
|
|
|
|
it("should handle failures gracefully without blocking execution", async () => {
|
|
// Mock all attempts to fail
|
|
fetch.mockRejectedValue(new Error("Persistent network error"));
|
|
|
|
const telemetryData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "research",
|
|
modelUsed: "claude-3-sonnet",
|
|
totalCost: 0.003,
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(telemetryData);
|
|
|
|
// Verify it attempted retries but failed gracefully
|
|
expect(fetch).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("Persistent network error");
|
|
});
|
|
|
|
it("should respect user opt-out preferences", async () => {
|
|
// Mock config to disable telemetry
|
|
mockGetConfig.mockReturnValue({ telemetryEnabled: false });
|
|
|
|
const telemetryData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "add-task",
|
|
totalCost: 0.001,
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(telemetryData);
|
|
|
|
// Verify no network request was made
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(true);
|
|
expect(result.skipped).toBe(true);
|
|
expect(result.reason).toBe("Telemetry disabled by user preference");
|
|
});
|
|
|
|
it("should validate telemetry data before submission", async () => {
|
|
const invalidTelemetryData = {
|
|
// Missing required fields
|
|
commandName: "test",
|
|
// Invalid timestamp format
|
|
timestamp: "invalid-date",
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(invalidTelemetryData);
|
|
|
|
// Verify no network request was made for invalid data
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("validation");
|
|
});
|
|
|
|
it("should handle HTTP error responses appropriately", async () => {
|
|
// Mock HTTP 429 error response (no retries for rate limiting)
|
|
fetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
json: async () => ({ error: "Rate limit exceeded" }),
|
|
});
|
|
|
|
const telemetryData = {
|
|
timestamp: "2025-05-28T15:00:00.000Z",
|
|
userId: "1234567890",
|
|
commandName: "update-task",
|
|
modelUsed: "claude-3-sonnet",
|
|
totalCost: 0.001,
|
|
};
|
|
|
|
const result =
|
|
await telemetrySubmission.submitTelemetryData(telemetryData);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("429");
|
|
expect(result.error).toContain("Too Many Requests");
|
|
expect(fetch).toHaveBeenCalledTimes(1); // No retries for 429
|
|
});
|
|
});
|
|
});
|