diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1ddfbb..7ddfc9ed47 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,5 +1,11 @@ + + + + + + diff --git a/core/data/log.test.ts b/core/data/log.test.ts new file mode 100644 index 0000000000..e2471b6bdf --- /dev/null +++ b/core/data/log.test.ts @@ -0,0 +1,192 @@ +import { DevDataLogEvent } from "@continuedev/config-yaml"; +import fs from "fs"; +import path from "path"; +import { IdeInfo, IdeSettings } from ".."; +import { Core } from "../core"; +import { getDevDataFilePath } from "../util/paths"; +import { DataLogger } from "./log"; + +// Only mock fetch, not fs +jest.mock("@continuedev/fetch"); + +const TEST_EVENT: DevDataLogEvent = { + name: "tokensGenerated", + data: { + generatedTokens: 100, + model: "gpt-4", + promptTokens: 50, + provider: "openai", + }, +}; +const SCHEMA = "0.2.0"; + +describe("DataLogger", () => { + let dataLogger: DataLogger; + const tempDir = path.join(process.cwd(), "temp-test-data"); + const testFilePath = path.join(tempDir, "tokensGenerated-test.jsonl"); + + // Create temp directory for test files + beforeAll(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + // Clean up temp directory after tests + afterAll(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Remove test file if it exists + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + + // Get singleton instance + dataLogger = DataLogger.getInstance(); + + // Mock core and required promises + dataLogger.core = { + configHandler: { + loadConfig: jest.fn().mockResolvedValue({ + config: { + data: [], + }, + }), + currentProfile: { + profileDescription: { + id: "test-profile-id", + }, + }, + controlPlaneClient: { + getAccessToken: jest.fn().mockResolvedValue("test-access-token"), + }, + }, + } as unknown as Core; + + dataLogger.ideSettingsPromise = Promise.resolve({ + userToken: "test-user-token", + } as IdeSettings); + + dataLogger.ideInfoPromise = Promise.resolve({ + name: "VSCode", + version: "1.0.0", + extensionVersion: "0.1.0", + } as IdeInfo); + }); + + describe("getInstance", () => { + it("should return the same instance when called multiple times", () => { + const instance1 = DataLogger.getInstance(); + const instance2 = DataLogger.getInstance(); + expect(instance1).toBe(instance2); + }); + }); + + describe("addBaseValues", () => { + it("should add base values to event data based on schema", async () => { + const mockZodSchema = { + shape: { + eventName: true, + timestamp: true, + schema: true, + userAgent: true, + selectedProfileId: true, + userId: true, + }, + }; + + const result = await dataLogger.addBaseValues( + { customField: "value" }, + "testEvent", + SCHEMA, + mockZodSchema as any, + ); + + expect(result).toEqual({ + customField: "value", + eventName: "testEvent", + timestamp: expect.any(String), + schema: SCHEMA, + userAgent: "VSCode/1.0.0 (Continue/0.1.0)", + selectedProfileId: "test-profile-id", + userId: "test-user-token", + }); + }); + + it("should not add fields not present in schema shape", async () => { + const mockZodSchema = { + shape: { + eventName: true, + // No other fields + }, + }; + + const result = await dataLogger.addBaseValues( + { customField: "value" }, + "testEvent", + SCHEMA, + mockZodSchema as any, + ); + + expect(result).toEqual({ + customField: "value", + eventName: "testEvent", + }); + }); + }); + + describe("logLocalData", () => { + it("should actually write data to the local file", async () => { + // Call the method to log data locally + await dataLogger.logLocalData(TEST_EVENT); + + // Verify the file was created + const filepath = getDevDataFilePath(TEST_EVENT.name, SCHEMA); + expect(fs.existsSync(filepath)).toBe(true); + + // Read file contents and verify + const fileContent = fs.readFileSync(filepath, "utf8"); + expect(fileContent).toContain('"generatedTokens":100'); + expect(fileContent).toContain('"model":"gpt-4"'); + expect(fileContent).toContain('"eventName":"tokensGenerated"'); + }); + }); + + describe("logDevData", () => { + it("should log data locally and to configured destinations", async () => { + // Spy on logLocalData and logToOneDestination + const logLocalDataSpy = jest + .spyOn(dataLogger, "logLocalData") + .mockResolvedValue(); + const logToOneDestinationSpy = jest + .spyOn(dataLogger, "logToOneDestination") + .mockResolvedValue(); + + // Mock config with multiple data destinations + const mockConfig = { + config: { + data: [ + { destination: "https://example.com/logs", schema: SCHEMA }, + { destination: "file:///logs", schema: SCHEMA }, + ], + }, + }; + + dataLogger.core!.configHandler.loadConfig = jest + .fn() + .mockResolvedValue(mockConfig); + + await dataLogger.logDevData(TEST_EVENT); + + expect(logLocalDataSpy).toHaveBeenCalledWith(TEST_EVENT); + expect(logToOneDestinationSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/core/data/log.ts b/core/data/log.ts index 4470eba728..c4cf27805d 100644 --- a/core/data/log.ts +++ b/core/data/log.ts @@ -12,7 +12,7 @@ import * as URI from "uri-js"; import { fileURLToPath } from "url"; import { AnyZodObject } from "zod"; import { Core } from "../core.js"; -import { IdeInfo, IdeSettings } from "../index.js"; +import { ContinueConfig, IdeInfo, IdeSettings } from "../index.js"; import { getDevDataFilePath } from "../util/paths.js"; import { joinPathsToUri } from "../util/uri.js"; @@ -68,8 +68,7 @@ export class DataLogger { return newBody; } - async logDevData(event: DevDataLogEvent) { - // Local logs (always on for all levels) + async logLocalData(event: DevDataLogEvent) { try { const filepath: string = getDevDataFilePath( event.name, @@ -100,126 +99,144 @@ export class DataLogger { } catch (error) { console.error("Error logging local dev data:", error); } + } + + async logDevData(event: DevDataLogEvent) { + // Local logs (always on for all levels) + await this.logLocalData(event); // Remote logs const config = (await this.core?.configHandler.loadConfig())?.config; if (config?.data?.length) { await Promise.allSettled( - config.data.map(async (dataConfig) => { - try { - // First extract the data schema based on the version and level - const { schema } = dataConfig; - - const level = dataConfig.level ?? DEFAULT_DEV_DATA_LEVEL; - - // Skip event if `events` is specified and does not include the event - const events = dataConfig.events ?? allDevEventNames; - if (!events.includes(event.name)) { - return; - } - - const versionSchemas = devDataVersionedSchemas[schema]; - if (!versionSchemas) { - throw new Error( - `Attempting to log dev data to non-existent version ${schema}`, - ); - } - - const levelSchemas = versionSchemas[level]; - if (!levelSchemas) { - throw new Error( - `Attempting to log dev data at level ${level} for version ${schema} which does not exist`, - ); - } - - const zodSchema = levelSchemas[event.name]; - if (!zodSchema) { - throw new Error( - `Attempting to log dev data for event ${event.name} at level ${level} for version ${schema}: no schema found`, - ); - } - - const eventDataWithBaseValues = await this.addBaseValues( - event.data, - event.name, + config.data.map((dataConfig) => + this.logToOneDestination(dataConfig, event), + ), + ); + } + } + + async parseEventData( + event: DevDataLogEvent, + schema: string, + level: "all" | "noCode", + ) { + const versionSchemas = devDataVersionedSchemas[schema]; + if (!versionSchemas) { + throw new Error( + `Attempting to log dev data to non-existent version ${schema}`, + ); + } + + const levelSchemas = versionSchemas[level]; + if (!levelSchemas) { + throw new Error( + `Attempting to log dev data at level ${level} for version ${schema} which does not exist`, + ); + } + + const zodSchema = levelSchemas[event.name]; + if (!zodSchema) { + throw new Error( + `Attempting to log dev data for event ${event.name} at level ${level} for version ${schema}: no schema found`, + ); + } + + const eventDataWithBaseValues = await this.addBaseValues( + event.data, + event.name, + schema, + zodSchema, + ); + + const parsed = zodSchema.safeParse(eventDataWithBaseValues); + if (!parsed.success) { + throw new Error( + `Failed to parse event data for event ${event.name} and schema ${schema}\n:${parsed.error.toString()}`, + ); + } + + return parsed.data; + } + + async logToOneDestination( + dataConfig: NonNullable[number], + event: DevDataLogEvent, + ) { + try { + if (!dataConfig) { + return; + } + + // First extract the data schema based on the version and level + const { schema } = dataConfig; + const level = dataConfig.level ?? DEFAULT_DEV_DATA_LEVEL; + + // Skip event if `events` is specified and does not include the event + const events = dataConfig.events ?? allDevEventNames; + if (!events.includes(event.name)) { + return; + } + + // Parse the event data, throwing if it fails + const parsed = await this.parseEventData(event, schema, level); + + const uriComponents = URI.parse(dataConfig.destination); + + // Send to remote server + if (uriComponents.scheme === "https" || uriComponents.scheme === "http") { + const headers: Record = { + "Content-Type": "application/json", + }; + + // If an API key is provided, use it, otherwise use the Continue access token + if (dataConfig.apiKey) { + headers["Authorization"] = `Bearer ${dataConfig.apiKey}`; + } else { + const accessToken = + await this.core?.configHandler.controlPlaneClient.getAccessToken(); + headers["Authorization"] = `Bearer ${accessToken}`; + } + + const profileId = + this.core?.configHandler.currentProfile?.profileDescription.id ?? ""; + const response = await fetchwithRequestOptions( + dataConfig.destination, + { + method: "POST", + headers, + body: JSON.stringify({ + name: event.name, + data: parsed, schema, - zodSchema, - ); - - const parsed = zodSchema.safeParse(eventDataWithBaseValues); - if (!parsed.success) { - throw new Error( - `Failed to parse event data for event ${event.name} and schema ${schema}\n:${parsed.error.toString()}`, - ); - } - - const uriComponents = URI.parse(dataConfig.destination); - - // Send to remote server - if ( - uriComponents.scheme === "https" || - uriComponents.scheme === "http" - ) { - const headers: Record = { - "Content-Type": "application/json", - }; - if (dataConfig.apiKey) { - headers["Authorization"] = `Bearer ${dataConfig.apiKey}`; - } - - // For events going to Continue, overwrite the access token - if ( - uriComponents.host?.endsWith(".continue.dev") || - uriComponents.host === "continue.dev" - ) { - // - const accessToken = - await this.core?.configHandler.controlPlaneClient.getAccessToken(); - headers["Authorization"] = `Bearer ${accessToken}`; - } - const profileId = - this.core?.configHandler.currentProfile?.profileDescription - .id ?? ""; - const response = await fetchwithRequestOptions( - dataConfig.destination, - { - method: "POST", - headers, - body: JSON.stringify({ - name: event.name, - data: parsed.data, - schema, - level, - profileId, - }), - }, - dataConfig.requestOptions, - ); - if (!response.ok) { - throw new Error( - `Post request failed. ${response.status}: ${response.statusText}`, - ); - } - } else if (uriComponents.scheme === "file") { - // Write to jsonc file for local file URIs - const dirUri = joinPathsToUri(dataConfig.destination, schema); - const dirPath = fileURLToPath(dirUri); - - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - const filepath = path.join(dirPath, `${event.name}.jsonl`); - const jsonLine = JSON.stringify(event.data); - fs.writeFileSync(filepath, `${jsonLine}\n`, { flag: "a" }); - } else { - throw new Error(`Unsupported URI scheme ${uriComponents.scheme}`); - } - } catch (error) { - console.error( - `Error logging data to ${dataConfig.destination}: ${error instanceof Error ? error.message : error}`, - ); - } - }), + level, + profileId, + }), + }, + dataConfig.requestOptions, + ); + if (!response.ok) { + throw new Error( + `Post request failed. ${response.status}: ${response.statusText}`, + ); + } + } else if (uriComponents.scheme === "file") { + // Write to jsonc file for local file URIs + const dirUri = joinPathsToUri(dataConfig.destination, schema); + const dirPath = fileURLToPath(dirUri); + + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + const filepath = path.join(dirPath, `${event.name}.jsonl`); + const jsonLine = JSON.stringify(event.data); + fs.writeFileSync(filepath, `${jsonLine}\n`, { flag: "a" }); + } else { + throw new Error(`Unsupported URI scheme ${uriComponents.scheme}`); + } + } catch (error) { + console.error( + `Error logging data to ${dataConfig.destination}: ${error instanceof Error ? error.message : error}`, ); } }