mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2141a3ff10 | |||
| 1edf1c7d9f | |||
| 5aefaaeede | |||
| b36f33eb28 | |||
| 56783d3255 | |||
| 46fd9894d7 | |||
| b169852039 | |||
| 48a77a2064 | |||
| 4f497d5b4a | |||
| 69be886106 | |||
| decd7e846c | |||
| 3944cdd464 | |||
| cdd5cf4eda | |||
| 054c6249a6 | |||
| 18b620afb1 | |||
| d6ba8282d9 | |||
| caa494aa95 | |||
| a6cb18ed8d | |||
| 7bc5ff7509 | |||
| 488eea0688 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
opt-in llm observability
|
||||
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"NODE_ENV": "debug",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@ const extensionConfig = {
|
||||
platform: "node",
|
||||
outfile: "dist/extension.js",
|
||||
external: ["vscode"],
|
||||
define: {
|
||||
"process.env": JSON.stringify({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
Generated
+14462
-450
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,11 @@
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Controls whether the MCP Marketplace is enabled."
|
||||
},
|
||||
"cline.conversationData": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Allow storing conversation data to improve Cline. Your conversations will be used to make Cline better. No personal information is ever shared with third parties."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +227,7 @@
|
||||
"watch:esbuild": "node esbuild.js --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
||||
"dev:package": "NODE_ENV=development vsce package",
|
||||
"compile-tests": "tsc -p ./tsconfig.test.json --outDir out",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
@@ -253,6 +259,7 @@
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vscode/test-cli": "^0.0.9",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"@vscode/vsce": "^3.2.2",
|
||||
"chai": "^4.3.10",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -271,6 +278,12 @@
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ClineHandler } from "../api/providers/cline"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
|
||||
import { telemetryService } from "../services/telemetry/TelemetryService"
|
||||
import { conversationTelemetryService, TelemetryChatMessage } from "../services/telemetry/ConversationTelemetryService"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
@@ -1347,6 +1348,26 @@ export class Cline {
|
||||
)
|
||||
}
|
||||
|
||||
// Capture system prompt for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.apiProvider && this.api.getModel().id) {
|
||||
const metadata = {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
}
|
||||
|
||||
const systemMessage: TelemetryChatMessage = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
ts: Date.now(), // we dont uniquely identify system messages, so we use the timestamp as the id
|
||||
}
|
||||
|
||||
// no need for timeout here, as there's no timestamp to compare to
|
||||
conversationTelemetryService.captureMessage(this.taskId, systemMessage, metadata)
|
||||
}
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]
|
||||
@@ -3120,6 +3141,42 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
|
||||
|
||||
// Capture message data for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
// This is done after a timeout to ensure the message is added to the conversation history
|
||||
setTimeout(() => {
|
||||
if (this.apiProvider && this.api.getModel().id) {
|
||||
const metadata = {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
}
|
||||
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Get the corresponding timestamp from clineMessages
|
||||
// The last message in clineMessages should be the one we just added
|
||||
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
const ts = lastClineMessage.ts
|
||||
|
||||
// Add the timestamp to the message object for telemetry
|
||||
const messageWithTs = {
|
||||
...lastMessage,
|
||||
ts,
|
||||
}
|
||||
|
||||
// Send individual message to telemetry
|
||||
conversationTelemetryService.captureMessage(this.taskId, messageWithTs, metadata)
|
||||
|
||||
// Send entire conversation history to cleanup endpoint
|
||||
// This ensures deleted messages are properly handled in telemetry
|
||||
conversationTelemetryService.cleanupTask(this.taskId, this.clineMessages)
|
||||
}
|
||||
}, 5)
|
||||
|
||||
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
|
||||
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
@@ -3197,6 +3254,38 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
|
||||
// Capture message data for telemetry after assistant response
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.apiProvider && this.api.getModel().id) {
|
||||
const metadata = {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
}
|
||||
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
// For assistant messages, we need to find the most recent "text" message
|
||||
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
|
||||
|
||||
// Add the timestamp to the message object for telemetry
|
||||
if (!lastTextMessage) {
|
||||
console.error("No text message found in clineMessages")
|
||||
} else {
|
||||
const messageWithTs = {
|
||||
...lastMessage,
|
||||
ts: lastTextMessage.ts,
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
conversationTelemetryService.captureMessage(this.taskId, messageWithTs, metadata)
|
||||
}, 5)
|
||||
}
|
||||
}
|
||||
|
||||
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
|
||||
this.didFinishAbortingStream = true
|
||||
}
|
||||
@@ -3327,6 +3416,34 @@ export class Cline {
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
})
|
||||
|
||||
// Capture message data for telemetry after assistant response,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.apiProvider && this.api.getModel().id) {
|
||||
const metadata = {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
}
|
||||
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
|
||||
|
||||
if (lastTextMessage) {
|
||||
const messageWithTs = {
|
||||
...lastMessage,
|
||||
ts: lastTextMessage.ts,
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
conversationTelemetryService.captureMessage(this.taskId, messageWithTs, metadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
|
||||
// in case the content blocks finished
|
||||
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
|
||||
|
||||
@@ -33,7 +33,8 @@ import { openMention } from "../mentions"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { TelemetrySetting } from "../../shared/TelemetrySetting"
|
||||
import { conversationTelemetryService } from "../../services/telemetry/ConversationTelemetryService"
|
||||
import { TelemetrySetting, ConversationDataSetting } from "../../shared/TelemetrySetting"
|
||||
import { cleanupLegacyCheckpoints } from "../../integrations/checkpoints/CheckpointMigration"
|
||||
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
|
||||
|
||||
@@ -103,6 +104,7 @@ type GlobalStateKey =
|
||||
| "togetherModelId"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "telemetrySetting"
|
||||
| "conversationDataSetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
| "planActSeparateModelsSetting"
|
||||
@@ -526,10 +528,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
})
|
||||
|
||||
// If user already opted in to telemetry, enable telemetry service
|
||||
this.getStateToPostToWebview().then((state) => {
|
||||
const { telemetrySetting } = state
|
||||
this.getStateToPostToWebview().then(async (state) => {
|
||||
const { telemetrySetting, conversationDataSetting, apiConfiguration } = state
|
||||
const clineApiKey = apiConfiguration?.clineApiKey
|
||||
const isOptedIn = telemetrySetting === "enabled"
|
||||
telemetryService.updateTelemetryState(isOptedIn)
|
||||
|
||||
// conversation telemetry is only enabled if user has opted in to conversation data telemetry
|
||||
const isConversationDataEnabled = conversationDataSetting === "enabled"
|
||||
conversationTelemetryService.updateTelemetryState(isConversationDataEnabled, clineApiKey)
|
||||
})
|
||||
break
|
||||
case "newTask":
|
||||
@@ -867,7 +874,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
break
|
||||
}
|
||||
// telemetry
|
||||
case "openSettings": {
|
||||
await this.postMessageToWebview({
|
||||
type: "action",
|
||||
@@ -919,7 +925,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.disposables,
|
||||
)
|
||||
}
|
||||
|
||||
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
|
||||
await this.updateGlobalState("telemetrySetting", telemetrySetting)
|
||||
const isOptedIn = telemetrySetting === "enabled"
|
||||
@@ -1845,6 +1850,11 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
planActSeparateModelsSetting,
|
||||
} = await this.getState()
|
||||
|
||||
// Get conversationDataSetting separately
|
||||
const conversationDataSetting = (await this.getGlobalState("conversationDataSetting")) as
|
||||
| ConversationDataSetting
|
||||
| undefined
|
||||
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
apiConfiguration,
|
||||
@@ -1865,6 +1875,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting,
|
||||
conversationDataSetting: conversationDataSetting || "unset",
|
||||
planActSeparateModelsSetting,
|
||||
vscMachineId: vscode.env.machineId,
|
||||
}
|
||||
|
||||
+61
-2
@@ -9,6 +9,8 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import assert from "node:assert"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
import { conversationTelemetryService } from "./services/telemetry/ConversationTelemetryService"
|
||||
import { TelemetrySetting, ConversationDataSetting } from "./shared/TelemetrySetting"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -30,6 +32,60 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
Logger.initialize(outputChannel)
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
// Initialize telemetry services
|
||||
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
const globalTelemetryEnabled = telemetryLevel === "all"
|
||||
|
||||
// Get user settings
|
||||
const telemetrySetting = context.globalState.get<TelemetrySetting>("telemetrySetting") || "unset"
|
||||
const conversationDataSetting = context.globalState.get<ConversationDataSetting>("conversationDataSetting") || "unset"
|
||||
|
||||
// Update telemetry services
|
||||
const telemetryEnabled = globalTelemetryEnabled && telemetrySetting === "enabled"
|
||||
const conversationDataEnabled = globalTelemetryEnabled && conversationDataSetting === "enabled"
|
||||
|
||||
telemetryService.updateTelemetryState(telemetryEnabled)
|
||||
context.secrets.get("clineApiKey").then((clineApiKey) => {
|
||||
conversationTelemetryService.updateTelemetryState(conversationDataEnabled, clineApiKey)
|
||||
})
|
||||
|
||||
// Sync VSCode settings with extension state
|
||||
// Only called when the setting is explicitly changed, not on initial load
|
||||
const syncConversationDataSetting = async () => {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const configValue = config.get<boolean>("conversationData")
|
||||
|
||||
if (configValue !== undefined) {
|
||||
// Convert boolean to "enabled"/"disabled" string
|
||||
const stringValue: ConversationDataSetting = configValue ? "enabled" : "disabled"
|
||||
|
||||
// Update the global state
|
||||
await context.globalState.update("conversationDataSetting", stringValue)
|
||||
|
||||
// Update telemetry service
|
||||
const isEnabled = configValue && globalTelemetryEnabled
|
||||
const clineApiKey = await context.secrets.get("clineApiKey")
|
||||
conversationTelemetryService.updateTelemetryState(isEnabled, clineApiKey)
|
||||
|
||||
// Update all providers
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (visibleProvider) {
|
||||
await visibleProvider.postStateToWebview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No initial sync - we want to preserve the "unset" state for new users
|
||||
|
||||
// Listen for configuration changes
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration("cline.conversationData")) {
|
||||
await syncConversationDataSetting()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
context.subscriptions.push(
|
||||
@@ -193,6 +249,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {
|
||||
telemetryService.shutdown()
|
||||
conversationTelemetryService.shutdown().catch((error) => {
|
||||
console.error("Error shutting down conversation telemetry:", error)
|
||||
})
|
||||
Logger.log("Cline extension deactivated")
|
||||
}
|
||||
|
||||
@@ -202,9 +261,9 @@ export function deactivate() {
|
||||
//
|
||||
// This is a workaround to reload the extension when the source code changes
|
||||
// since vscode doesn't support hot reload for extensions
|
||||
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
|
||||
const { NODE_ENV, DEV_WORKSPACE_FOLDER } = process.env
|
||||
|
||||
if (IS_DEV && IS_DEV !== "false") {
|
||||
if (NODE_ENV && NODE_ENV === "debug") {
|
||||
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(DEV_WORKSPACE_FOLDER, "src/**/*"))
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { trace, context, SpanKind, SpanStatusCode } from "@opentelemetry/api"
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
|
||||
import { Resource } from "@opentelemetry/resources"
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
export type TelemetryChatMessage = {
|
||||
role: "user" | "assistant" | "system"
|
||||
ts: number
|
||||
content: Anthropic.Messages.MessageParam["content"]
|
||||
}
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
const IS_DEV = Boolean(process.env.NODE_ENV === "development" || process.env.NODE_ENV === "debug")
|
||||
|
||||
interface ConversationMetadata {
|
||||
apiProvider: string
|
||||
model: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for collecting conversation data using OpenTelemetry
|
||||
*/
|
||||
export class ConversationTelemetryService {
|
||||
private static instance: ConversationTelemetryService
|
||||
private enabled: boolean = false
|
||||
private distinctId: string
|
||||
private clineApiKey?: string
|
||||
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
|
||||
private tracerProvider: NodeTracerProvider | undefined
|
||||
private tracer: any
|
||||
private messageIndices: Map<string, number> = new Map()
|
||||
|
||||
private constructor(distinctId: string) {
|
||||
this.distinctId = distinctId
|
||||
}
|
||||
|
||||
public static getInstance(distinctId: string): ConversationTelemetryService {
|
||||
if (!ConversationTelemetryService.instance) {
|
||||
ConversationTelemetryService.instance = new ConversationTelemetryService(distinctId)
|
||||
}
|
||||
return ConversationTelemetryService.instance
|
||||
}
|
||||
|
||||
public updateTelemetryState(enabled: boolean, clineApiKey?: string): void {
|
||||
console.log("[ConversationTelemetry] Updating telemetry state...", { enabled, clineApiKey })
|
||||
|
||||
// First update the state variables
|
||||
this.enabled = enabled
|
||||
this.clineApiKey = clineApiKey
|
||||
|
||||
// Then initialize the tracer if needed
|
||||
if (this.enabled && !this.tracer && IS_DEV) {
|
||||
this.initializeTracer()
|
||||
}
|
||||
}
|
||||
|
||||
private initializeTracer(): void {
|
||||
try {
|
||||
// Create a resource that identifies our service
|
||||
const resource = new Resource({
|
||||
[ATTR_SERVICE_NAME]: "cline-extension",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
})
|
||||
|
||||
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...", { "this.clineApiKey": this.clineApiKey })
|
||||
|
||||
// Configure the OTLP exporter
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers if available
|
||||
if (this.clineApiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.clineApiKey}`
|
||||
}
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: this.apiEndpoint,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Create the span processor
|
||||
const spanProcessor = new SimpleSpanProcessor(exporter as any)
|
||||
|
||||
// Create the trace provider with the span processor in the config
|
||||
this.tracerProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [spanProcessor as any],
|
||||
})
|
||||
|
||||
// Register the provider
|
||||
this.tracerProvider.register()
|
||||
|
||||
// Get a tracer
|
||||
this.tracer = trace.getTracer("cline-conversation-tracer")
|
||||
|
||||
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a message in the conversation as an OpenTelemetry span
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata): void {
|
||||
// Do NOT capture message if user has not explicitly opted in
|
||||
if (!this.enabled || !this.tracer || !IS_DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert taskId to a valid trace ID (must be 32 hex chars)
|
||||
const traceId = this.generateTraceIdFromTimestamp(taskId)
|
||||
|
||||
// Convert message timestamp to a valid span ID (must be 16 hex chars)
|
||||
if (!message.ts && message.ts !== 0) {
|
||||
throw new Error("Message timestamp is required")
|
||||
}
|
||||
|
||||
const timestamp = message.ts
|
||||
const spanId = this.generateSpanIdFromTimestamp(timestamp)
|
||||
|
||||
// Create a span context with our IDs
|
||||
const spanContext = trace.setSpanContext(context.active(), {
|
||||
traceId,
|
||||
spanId,
|
||||
isRemote: false,
|
||||
traceFlags: 1, // Sampled
|
||||
})
|
||||
|
||||
// Start a new span with the context
|
||||
const span = this.tracer.startSpan(
|
||||
`message.${message.role}`,
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
|
||||
},
|
||||
spanContext,
|
||||
)
|
||||
|
||||
// Get the message index for this task
|
||||
const messageIndex = this.getNextMessageIndex(taskId)
|
||||
|
||||
// Add attributes to the span
|
||||
span.setAttribute("task.id", taskId)
|
||||
span.setAttribute("user.id", this.distinctId)
|
||||
span.setAttribute("message.role", message.role)
|
||||
span.setAttribute("message.timestamp", timestamp)
|
||||
span.setAttribute("message.index", messageIndex)
|
||||
|
||||
const c = message.content
|
||||
|
||||
// Add Braintrust-compatible attributes
|
||||
span.setAttribute("gen_ai.request.model", metadata.model)
|
||||
|
||||
if (message.role === "user") {
|
||||
span.setAttribute("gen_ai.prompt", this.extractContent(message))
|
||||
} else if (message.role === "assistant") {
|
||||
span.setAttribute("gen_ai.completion", this.extractContent(message))
|
||||
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
|
||||
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
|
||||
} else if (message.role === "system") {
|
||||
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
|
||||
}
|
||||
|
||||
// Add custom metadata in Braintrust format
|
||||
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
|
||||
span.setAttribute("braintrust.metadata.ts", message.ts)
|
||||
|
||||
// End the span immediately since messages are discrete events
|
||||
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
|
||||
|
||||
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error capturing message:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
|
||||
*/
|
||||
private generateTraceIdFromTimestamp(timestamp: string): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
|
||||
return hex.substring(0, 32) // Ensure it's exactly 32 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
|
||||
* Returns [seconds, nanoseconds]
|
||||
*/
|
||||
private millisecondsToHrTime(milliseconds: number): [number, number] {
|
||||
return [
|
||||
Math.floor(milliseconds / 1000), // seconds
|
||||
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid span ID (16 hex chars)
|
||||
*/
|
||||
private generateSpanIdFromTimestamp(timestamp: number): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
|
||||
return hex.substring(0, 16) // Ensure it's exactly 16 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content from different message formats
|
||||
*/
|
||||
private extractContent(message: TelemetryChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content
|
||||
}
|
||||
|
||||
return message.content
|
||||
.map((block) => (block.type === "text" ? block.text : null))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Track message indices per task
|
||||
*/
|
||||
private getNextMessageIndex(taskId: string): number {
|
||||
const currentIndex = this.messageIndices.get(taskId) || 0
|
||||
this.messageIndices.set(taskId, currentIndex + 1)
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
|
||||
// Do NOT send data if user has not explicitly opted in
|
||||
if (!this.enabled || !this.clineApiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure the headers with API key
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers
|
||||
headers["Authorization"] = `Bearer ${this.clineApiKey}`
|
||||
|
||||
// Send the data to the cleanup endpoint
|
||||
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
|
||||
|
||||
// Use fetch API to send the data
|
||||
const response = await fetch(cleanupEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
taskId: taskId,
|
||||
conversationData,
|
||||
userId: this.distinctId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the tracer provider
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
if (this.tracerProvider) {
|
||||
await this.tracerProvider.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationTelemetryService = ConversationTelemetryService.getInstance(vscode.env.machineId)
|
||||
@@ -7,7 +7,7 @@ import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { TelemetrySetting, ConversationDataSetting } from "./TelemetrySetting"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -98,6 +98,7 @@ export interface ExtensionState {
|
||||
}
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
telemetrySetting: TelemetrySetting
|
||||
conversationDataSetting?: ConversationDataSetting
|
||||
planActSeparateModelsSetting: boolean
|
||||
vscMachineId: string
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export type TelemetrySetting = "unset" | "enabled" | "disabled"
|
||||
export type ConversationDataSetting = "unset" | "enabled" | "disabled"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import { ChatContent } from "./ChatContent"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { TelemetrySetting, ConversationDataSetting } from "./TelemetrySetting"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
|
||||
@@ -16,7 +16,7 @@ import ApiOptions from "./ApiOptions"
|
||||
import { TabButton } from "../mcp/McpView"
|
||||
import { useEvent } from "react-use"
|
||||
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
const { IS_DEV } = process.env
|
||||
const { NODE_ENV } = process.env
|
||||
|
||||
type SettingsViewProps = {
|
||||
onDone: () => void
|
||||
@@ -31,6 +31,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
openRouterModels,
|
||||
telemetrySetting,
|
||||
setTelemetrySetting,
|
||||
conversationDataSetting,
|
||||
chatSettings,
|
||||
planActSeparateModelsSetting,
|
||||
setPlanActSeparateModelsSetting,
|
||||
@@ -72,7 +73,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
planActSeparateModelsSetting,
|
||||
customInstructionsSetting: customInstructions,
|
||||
telemetrySetting,
|
||||
apiConfiguration: apiConfigurationToSubmit,
|
||||
apiConfiguration: apiConfiguration,
|
||||
})
|
||||
|
||||
if (!withoutDone) {
|
||||
@@ -278,8 +279,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
for more details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{IS_DEV && (
|
||||
{(NODE_ENV === "development" || NODE_ENV === "debug") && (
|
||||
<>
|
||||
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
|
||||
<VSCodeButton onClick={handleResetState} style={{ marginTop: "5px", width: "auto" }}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings"
|
||||
import { DEFAULT_CHAT_SETTINGS } from "../../../src/shared/ChatSettings"
|
||||
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
|
||||
import { TelemetrySetting, ConversationDataSetting } from "../../../src/shared/TelemetrySetting"
|
||||
|
||||
interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
|
||||
@@ -37,8 +37,7 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
|
||||
IS_DEV: JSON.stringify(process.env.IS_DEV),
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user