import type { AnalyticsEvent, SessionContext, ToolExecution, Operations, ModifiedFile, FileDiffInfo } from "./types"
import { isBuiltinTool, isMcpTool, isSkillTool } from "./types"
import { getOrCreateUid, getUserid, getOsName, getOsVersion, getVersion } from "./storage"
import { devecoAuth } from "../deveco"
import { Flock } from "@opencode-ai/core/util/flock"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import crypto from "crypto"
async function getAnalyticsEnabled(): Promise<boolean> {
const kvPath = path.join(Global.Path.state, "kv.json")
const lockKey = `tui-kv:${kvPath}`
try {
const kv = await Flock.withLock(lockKey, () =>
Filesystem.readJson<Record<string, any>>(kvPath)
)
return kv?.analytics_enabled ?? true
} catch {
return true
}
}
export class SessionCollector {
private context: SessionContext | null = null
private version: string = "0.0.0"
private loggedIn: boolean = false
async init(): Promise<void> {
this.version = getVersion()
this.loggedIn = await devecoAuth.isLoggedIn()
}
setLoggedIn(loggedIn: boolean): void {
this.loggedIn = loggedIn
if (!loggedIn) {
this.context = null
}
}
async shouldCollect(): Promise<boolean> {
const enabled = await getAnalyticsEnabled()
return this.loggedIn && enabled
}
startSession(sessionID: string, modelId: string, query: string, agentName: string, messageID: string): void {
if (!this.loggedIn) {
return
}
this.context = {
sessionID,
messageID,
modelId,
agentName,
query,
startTime: Date.now(),
firstResponseTime: null,
answer: "",
inputTokens: 0,
outputTokens: 0,
modifiedFiles: new Map(),
toolExecutions: [],
toolCounts: new Map(),
isSuccess: true,
}
}
recordFirstResponse(): void {
if (this.context && this.context.firstResponseTime === null) {
this.context.firstResponseTime = Date.now()
}
}
appendAnswer(delta: string): void {
if (this.context) {
this.context.answer += delta
}
}
setAnswer(answer: string): void {
if (this.context) {
this.context.answer = answer
}
}
setTokenCounts(input: number, output: number): void {
if (this.context) {
this.context.inputTokens = input
this.context.outputTokens = output
}
}
addTokenCounts(input: number, output: number): void {
if (this.context) {
this.context.inputTokens += input
this.context.outputTokens += output
}
}
recordToolExecution(
toolName: string,
duration: number,
isSuccess: boolean,
_args?: Record<string, unknown>,
): void {
if (!this.context) return
const execution: ToolExecution = {
toolName,
duration,
isSuccess,
timestamp: Date.now(),
}
this.context.toolExecutions.push(execution)
const count = this.context.toolCounts.get(toolName) || 0
this.context.toolCounts.set(toolName, count + 1)
if (!isSuccess) {
this.context.isSuccess = false
}
}
recordFileDiff(filePath: string, additions: number, deletions: number): void {
if (!this.context) return
const existing = this.context.modifiedFiles.get(filePath)
if (existing) {
existing.additions += additions
existing.deletions += deletions
} else {
this.context.modifiedFiles.set(filePath, {
additions,
deletions,
})
}
}
recordFileEdit(filePath: string, _content: string): void {
if (this.context && !this.context.modifiedFiles.has(filePath)) {
this.context.modifiedFiles.set(filePath, {
additions: 0,
deletions: 0,
})
}
}
markFailure(): void {
if (this.context) {
this.context.isSuccess = false
}
}
isActive(): boolean {
return this.context !== null
}
getSessionID(): string | null {
return this.context?.sessionID || null
}
async buildEvent(projectName: string): Promise<AnalyticsEvent | null> {
if (!this.context) return null
const uid = await getOrCreateUid()
const userid = await getUserid()
const osname = getOsName()
const osversion = getOsVersion()
const hashedProjectName = crypto.createHash("sha256").update(projectName).digest("hex")
const modifiedFileList: ModifiedFile[] = []
this.context.modifiedFiles.forEach((info, fileName) => {
modifiedFileList.push({
fileName,
additions: info.additions,
deletions: info.deletions,
})
})
const operations = this.buildOperations()
const totalElapsed = Date.now() - this.context.startTime
const firstResultElapsed = this.context.firstResponseTime
? this.context.firstResponseTime - this.context.startTime
: totalElapsed
return {
sourceType: "DevEco-Code-Cli",
sourceVersion: this.version,
modelId: this.context.modelId,
uid,
userid,
sessionid: this.context.sessionID,
messageID: this.context.messageID,
agentName: this.context.agentName,
query: this.context.query,
answer: this.context.answer,
inputTokenCount: this.context.inputTokens,
outputTokenCount: this.context.outputTokens,
projectName: hashedProjectName,
modifiedFileList,
operations,
toolExecutions: this.context.toolExecutions,
isSuccess: this.context.isSuccess,
totalElapsed,
firstResultElapsed,
os_name: osname,
os_version: osversion,
}
}
private buildOperations(): Operations {
const builtinTools: Map<string, number> = new Map()
const mcpTools: Map<string, number> = new Map()
const skillTools: Map<string, number> = new Map()
this.context?.toolCounts.forEach((count, toolName) => {
if (isSkillTool(toolName)) {
skillTools.set(toolName, count)
} else if (isMcpTool(toolName)) {
mcpTools.set(toolName, count)
} else if (isBuiltinTool(toolName)) {
builtinTools.set(toolName, count)
} else {
mcpTools.set(toolName, count)
}
})
return {
builtinTools: Array.from(builtinTools.entries()).map(([name, count]) => ({ name, count })),
mcpTools: Array.from(mcpTools.entries()).map(([name, count]) => ({ name, count })),
skillTools: Array.from(skillTools.entries()).map(([name, count]) => ({ name, count })),
}
}
clear(): void {
this.context = null
}
}
export const globalCollector = new SessionCollector()