import fs from "fs"
import path from "path"
import type { AnalyticsEvent, AnalyticsConfig, HuaweiTracePayload } from "./types"
import { DEFAULT_CONFIG } from "./types"
import { appendPendingEvent, clearPendingEvents, getPendingEvents } from "./storage"
import { devecoAuth, ACCESS_TOKEN_EXPIRES_MS, saveAuthToDisk } from "../deveco"
import { Global } from "@opencode-ai/core/global"
import { LocalCrypto } from "@/security/local-crypto"
const ANALYTICS_DIR = path.join(Global.Path.data, "analytics", "log")
const LOG_FILE = path.join(ANALYTICS_DIR, "analytics.log")
interface UploadResult {
success: boolean
error?: string
}
interface AuthInfo {
type: string
access?: string
refresh?: string
expires?: number
key?: string
}
function readAuthFromDisk(providerID: string): AuthInfo | undefined {
try {
const authFilePath = path.join(Global.Path.data, "auth.json")
if (!fs.existsSync(authFilePath)) return undefined
const encrypted = JSON.parse(fs.readFileSync(authFilePath, "utf8")) as Record<string, unknown>
const data = LocalCrypto.decryptAuthData(encrypted)
return data[providerID] as AuthInfo | undefined
} catch {
return undefined
}
}
export class AnalyticsUploader {
private config: AnalyticsConfig
private eventQueue: AnalyticsEvent[] = []
private flushTimer: ReturnType<typeof setInterval> | null = null
private isUploading = false
private retryCount = 0
constructor(config: Partial<AnalyticsConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
async restorePending(): Promise<void> {
try {
const pending = await getPendingEvents()
if (pending.length > 0) {
this.eventQueue = pending as AnalyticsEvent[]
await this.writeLog(`Restored ${pending.length} pending events from disk`)
}
} catch (err) {
await this.writeLog(`Failed to restore pending events: ${err}`)
}
}
private async hashSha256(value: string): Promise<string> {
const data = new TextEncoder().encode(value)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
private async transformEvent(event: AnalyticsEvent): Promise<HuaweiTracePayload> {
const hashedUserid = await this.hashSha256(event.userid)
const detail = { ...event, userid: hashedUserid }
return {
action: "DevEco-Code",
countryCode: "CN",
detail: JSON.stringify(detail),
osArch: event.os_name,
sid: 10200,
timestamp: Date.now(),
uid: event.uid,
version: event.sourceVersion,
}
}
private async writeLog(message: string): Promise<void> {
try {
fs.mkdirSync(ANALYTICS_DIR, { recursive: true })
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${message}\n`
fs.appendFileSync(LOG_FILE, line, "utf-8")
} catch {
}
}
async upload(event: AnalyticsEvent): Promise<boolean> {
if (!this.config.enabled) {
return false
}
try {
await appendPendingEvent(event)
} catch (err) {
await this.writeLog(`Failed to persist event to disk: ${err}`)
}
this.eventQueue.push(event)
if (this.eventQueue.length > this.config.maxQueueSize) {
const dropped = this.eventQueue.length - this.config.maxQueueSize
this.eventQueue = this.eventQueue.slice(-this.config.maxQueueSize)
await this.writeLog(`Queue full, dropped ${dropped} old events`)
}
if (this.eventQueue.length >= this.config.batchSize) {
await this.flush()
}
return true
}
private async resolveAuth(): Promise<string | null> {
const authInfo = readAuthFromDisk("deveco")
let authToken = ""
let tokenExpires = 0
let refreshToken = ""
if (authInfo?.type === "oauth") {
authToken = authInfo.access || ""
tokenExpires = authInfo.expires || 0
refreshToken = authInfo.refresh || ""
} else if (authInfo?.type === "api") {
authToken = authInfo.key || ""
}
if (!authToken) {
await this.writeLog("No auth token, skipping upload")
return null
}
if (tokenExpires && Date.now() >= tokenExpires) {
if (refreshToken) {
const newTokens = await devecoAuth.refreshToken()
if (newTokens) {
await saveAuthToDisk("deveco", {
type: "oauth",
access: newTokens.accessToken,
refresh: newTokens.refreshToken,
expires: Date.now() + ACCESS_TOKEN_EXPIRES_MS,
})
return newTokens.accessToken
}
await this.writeLog("Token refresh failed, skipping upload")
return null
}
await this.writeLog("No refresh token available, skipping upload")
return null
}
return authToken
}
private async handleRetryExceeded(): Promise<void> {
await this.writeLog(`Max retries (${this.config.maxRetries}) exceeded, discarding ${this.eventQueue.length} events`)
this.eventQueue = []
this.retryCount = 0
await clearPendingEvents().catch(() => {})
}
private async sendEvents(authToken: string): Promise<UploadResult> {
const events = [...this.eventQueue]
const payload = await Promise.all(events.map((e) => this.transformEvent(e)))
await this.writeLog(`Uploading ${events.length} event(s)`)
const response = await fetch(this.config.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", authorization: authToken },
body: JSON.stringify(payload),
})
if (!response.ok) {
await this.writeLog(`Upload failed: HTTP ${response.status}, ${events.length} event(s)`)
this.retryCount++
if (this.retryCount > this.config.maxRetries) await this.handleRetryExceeded()
return { success: false, error: `HTTP ${response.status}` }
}
await this.writeLog(`Upload success: ${response.status}, ${events.length} event(s)`)
this.eventQueue = []
this.retryCount = 0
await clearPendingEvents()
return { success: true }
}
async flush(): Promise<UploadResult> {
if (this.isUploading || this.eventQueue.length === 0) return { success: true }
const authToken = await this.resolveAuth()
if (!authToken) {
this.eventQueue = []
this.retryCount = 0
await clearPendingEvents().catch(() => {})
return { success: true }
}
this.isUploading = true
try {
return await this.sendEvents(authToken)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await this.writeLog(`Upload error: ${errorMessage}`)
this.retryCount++
if (this.retryCount > this.config.maxRetries) await this.handleRetryExceeded()
return { success: false, error: errorMessage }
} finally {
this.isUploading = false
}
}
getQueueLength(): number {
return this.eventQueue.length
}
clearQueue(): void {
this.eventQueue = []
this.retryCount = 0
}
startPeriodicFlush(): void {
if (this.flushTimer) return
this.flushTimer = setInterval(() => {
this.flush().catch((err) => this.writeLog(`Periodic flush error: ${err}`))
}, this.config.flushInterval)
}
stopPeriodicFlush(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
this.flushTimer = null
}
}
async shutdown(): Promise<void> {
this.stopPeriodicFlush()
if (this.eventQueue.length > 0) {
await this.writeLog("Shutting down, flushing remaining events...")
await this.flush()
}
}
}
export const globalUploader = new AnalyticsUploader()
export async function uploadAnalyticsEvent(event: AnalyticsEvent): Promise<boolean> {
return globalUploader.upload(event)
}
export async function flushAnalyticsEvents(): Promise<UploadResult> {
return globalUploader.flush()
}
export function clearAnalyticsQueue(): void {
globalUploader.clearQueue()
}