Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object MessageExportService {

enum class ExportFormat { PDF, WORD }

private val COPYRIGHT = "Generated by Askimo · Copyright © ${Year.now().value} · https://askimo.io"
private val COPYRIGHT = "Generated by Askimo · Copyright © ${Year.now().value} · https://askimo.chat"

/**
* Exports [content] (markdown) to [targetFile] in the given [format].
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.ui.service

import io.askimo.core.i18n.LocalizationManager
import io.askimo.core.logging.logger
import io.askimo.core.telemetry.TelemetryMetrics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

/**
* Exports a [TelemetryMetrics] snapshot to a ZIP file containing two CSVs:
*
* - `session-metrics.csv` — aggregated RAG and LLM summary metrics.
* - `model-token-usage.csv` — per-provider/model LLM call breakdown.
*
* CSV conventions:
* - UTF-8 encoding, comma delimiter, RFC 4180 quoting (all fields quoted).
* - English-only stable column headers for downstream tooling compatibility.
* - Numeric values formatted via [LocalizationManager] using the current user locale.
* - Decimal values use [LocalizationManager.formatDouble] with 2 fraction digits.
* - Empty rows are written (header only) when a section has no data.
* - Timestamps are ISO-8601 UTC.
*/
object TelemetryExportService {

private val log = logger<TelemetryExportService>()
private val timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)

/**
* Exports [metrics] to [targetZipFile] as a ZIP with two CSV entries.
*
* @param metrics Current telemetry snapshot (may have all-zero values).
* @param targetZipFile Destination file; parent directories are created if needed.
* @return [Result.success] on completion, [Result.failure] on any error.
*/
suspend fun export(metrics: TelemetryMetrics, targetZipFile: File): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
targetZipFile.parentFile?.mkdirs()
val capturedAt = timestampFormatter.format(Instant.now())

ZipOutputStream(targetZipFile.outputStream().buffered()).use { zos ->
// ── session-metrics.csv ──────────────────────────────────
zos.putNextEntry(ZipEntry("session-metrics.csv"))
zos.write(buildSessionMetricsCsv(metrics, capturedAt).toByteArray(Charsets.UTF_8))
zos.closeEntry()

// ── model-token-usage.csv ────────────────────────────────
zos.putNextEntry(ZipEntry("model-token-usage.csv"))
zos.write(buildModelTokenUsageCsv(metrics, capturedAt).toByteArray(Charsets.UTF_8))
zos.closeEntry()
}

log.info("Telemetry exported to {}", targetZipFile.absolutePath)
}.onFailure { e ->
log.error("Failed to export telemetry", e)
}
}

// ── CSV builders ─────────────────────────────────────────────────────────

private fun buildSessionMetricsCsv(metrics: TelemetryMetrics, capturedAt: String): String {
val sw = StringWriter()
sw.appendCsvLine(
"captured_at",
"metric_key",
"metric_label",
"unit",
"value",
)

// RAG metrics — only if any RAG data was collected
if (metrics.ragClassificationTotal > 0) {
sw.appendCsvLine(capturedAt, "rag_classification_total", "RAG Classification Total", "count", LocalizationManager.formatNumber(metrics.ragClassificationTotal))
sw.appendCsvLine(capturedAt, "rag_triggered", "RAG Triggered", "count", LocalizationManager.formatNumber(metrics.ragTriggered))
sw.appendCsvLine(capturedAt, "rag_skipped", "RAG Skipped", "count", LocalizationManager.formatNumber(metrics.ragSkipped))
sw.appendCsvLine(capturedAt, "rag_triggered_percent", "RAG Triggered Percent", "%", LocalizationManager.formatDouble(metrics.ragTriggeredPercent, 2))
sw.appendCsvLine(capturedAt, "rag_avg_classification_time_ms", "RAG Avg Classification Time", "ms", LocalizationManager.formatNumber(metrics.ragAvgClassificationTimeMs))
}

if (metrics.ragRetrievalTotal > 0) {
sw.appendCsvLine(capturedAt, "rag_retrieval_total", "RAG Retrieval Total", "count", LocalizationManager.formatNumber(metrics.ragRetrievalTotal))
sw.appendCsvLine(capturedAt, "rag_avg_retrieval_time_ms", "RAG Avg Retrieval Time", "ms", LocalizationManager.formatNumber(metrics.ragAvgRetrievalTimeMs))
sw.appendCsvLine(capturedAt, "rag_avg_chunks_retrieved", "RAG Avg Chunks Retrieved", "count", LocalizationManager.formatDouble(metrics.ragAvgChunksRetrieved, 2))
}

// LLM summary metrics
if (metrics.llmCallsByProvider.isNotEmpty()) {
val totalCalls = metrics.llmCallsByProvider.values.sum()
val totalTokens = metrics.llmTokensByProvider.values.sum()
val totalErrors = metrics.llmErrorsByProvider.values.sum()
sw.appendCsvLine(capturedAt, "llm_total_calls", "LLM Total Calls", "count", LocalizationManager.formatNumber(totalCalls))
sw.appendCsvLine(capturedAt, "llm_total_tokens", "LLM Total Tokens", "tokens", LocalizationManager.formatNumber(totalTokens))
sw.appendCsvLine(capturedAt, "llm_total_errors", "LLM Total Errors", "count", LocalizationManager.formatNumber(totalErrors))
}

return sw.toString()
}

private fun buildModelTokenUsageCsv(metrics: TelemetryMetrics, capturedAt: String): String {
val sw = StringWriter()
sw.appendCsvLine(
"captured_at",
"provider",
"model",
"calls",
"tokens",
"avg_duration_ms",
"errors",
)

metrics.llmCallsByProvider.forEach { (providerModel, calls) ->
val parts = providerModel.split(":", limit = 2)
val provider = parts.getOrElse(0) { providerModel }
val model = parts.getOrElse(1) { "" }
val tokens = metrics.llmTokensByProvider[providerModel] ?: 0L
val avgDurationMs = metrics.llmAvgDurationMsByProvider[providerModel] ?: 0L
val errors = metrics.llmErrorsByProvider[providerModel] ?: 0

sw.appendCsvLine(
capturedAt,
provider,
model,
LocalizationManager.formatNumber(calls),
LocalizationManager.formatNumber(tokens),
LocalizationManager.formatNumber(avgDurationMs),
LocalizationManager.formatNumber(errors),
)
}

return sw.toString()
}

// ── CSV helpers ───────────────────────────────────────────────────────────

/**
* Appends a RFC 4180 CSV row: all fields are quoted, internal quotes are doubled,
* newlines within values are escaped as \n.
*/
private fun StringWriter.appendCsvLine(vararg fields: Any) {
append(
fields.joinToString(",") { field ->
"\"${field.toString().replace("\"", "\"\"").replace("\n", "\\n").replace("\r", "")}\""
},
)
append("\n")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,15 @@ import org.koin.java.KoinJavaComponent.get
* The [telemetryContent] slot is intentionally left open so that each module
* can pass its own `telemetryPanel` composable (community vs. Pro flavours
* differ in implementation but share this outer shell).
*
* @param onExportTelemetry Called when the user clicks "Export CSV". The caller
* is responsible for picking a save path and invoking the telemetry export service.
* Pass `null` to hide the export button (e.g. when no telemetry data exists).
*/
@Composable
fun systemResourcesDialog(
onDismiss: () -> Unit,
onExportTelemetry: (() -> Unit)?,
telemetryContent: @Composable () -> Unit,
) {
val monitor = remember { get<SystemResourceMonitor>(SystemResourceMonitor::class.java) }
Expand Down Expand Up @@ -140,12 +145,19 @@ fun systemResourcesDialog(

Spacer(Modifier.height(Spacing.large))

// ── Close button ─────────────────────────────────────────
secondaryButton(
onClick = onDismiss,
// ── Action buttons ────────────────────────────────────────
Row(
modifier = Modifier.align(Alignment.End),
horizontalArrangement = Arrangement.spacedBy(Spacing.small),
) {
Text(stringResource("action.close"))
if (onExportTelemetry != null) {
secondaryButton(onClick = onExportTelemetry) {
Text(stringResource("telemetry.export.csv"))
}
}
secondaryButton(onClick = onDismiss) {
Text(stringResource("action.close"))
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,12 @@ system.diagnostics.jvm.memory=JVM Memory (used / total)
system.diagnostics.cpu.usage=CPU Usage
system.diagnostics.telemetry=Telemetry

# Telemetry export
telemetry.export.csv=Export CSV
telemetry.export.dialog.title=Export Telemetry as ZIP
telemetry.export.error.title=Export Failed
telemetry.export.error.message=Failed to export telemetry data: {0}

# Telemetry
telemetry.reset=Reset telemetry data
telemetry.title=📊 Session Metrics
Expand Down
6 changes: 6 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM-Speicher (verwendet / gesamt)
system.diagnostics.cpu.usage=CPU-Auslastung
system.diagnostics.telemetry=Telemetrie

# Telemetry export
telemetry.export.csv=CSV exportieren
telemetry.export.dialog.title=Telemetrie als ZIP exportieren
telemetry.export.error.title=Export fehlgeschlagen
telemetry.export.error.message=Telemetriedaten konnten nicht exportiert werden: {0}

# Telemetry
telemetry.reset=Telemetriedaten zurücksetzen
telemetry.title=📊 Sitzungsmetriken
Expand Down
6 changes: 6 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=Memoria JVM (usada / total)
system.diagnostics.cpu.usage=Uso de CPU
system.diagnostics.telemetry=Telemetría

# Telemetry export
telemetry.export.csv=Exportar CSV
telemetry.export.dialog.title=Exportar Telemetría como ZIP
telemetry.export.error.title=Error de Exportación
telemetry.export.error.message=Error al exportar datos de telemetría: {0}

# Telemetry
telemetry.reset=Restablecer datos de telemetría
telemetry.title=📊 Métricas de sesión
Expand Down
6 changes: 6 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,12 @@ system.diagnostics.jvm.memory=Mémoire JVM (utilisée / totale)
system.diagnostics.cpu.usage=Utilisation du CPU
system.diagnostics.telemetry=Télémétrie

# Telemetry export
telemetry.export.csv=Exporter CSV
telemetry.export.dialog.title=Exporter la télémétrie en ZIP
telemetry.export.error.title=Échec de l'exportation
telemetry.export.error.message=Échec de l'exportation des données de télémétrie : {0}

# Telemetry
telemetry.reset=Réinitialiser les données de télémétrie
telemetry.title=📊 Métriques de session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM メモリ (使用済み / 合計)
system.diagnostics.cpu.usage=CPU 使用率
system.diagnostics.telemetry=テレメトリ

# Telemetry export
telemetry.export.csv=CSV エクスポート
telemetry.export.dialog.title=テレメトリを ZIP としてエクスポート
telemetry.export.error.title=エクスポート失敗
telemetry.export.error.message=テレメトリデータのエクスポートに失敗しました: {0}

# Telemetry
telemetry.reset=テレメトリデータをリセット
telemetry.title=📊 セッション指標
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=JVM 메모리 (사용됨 / 전체)
system.diagnostics.cpu.usage=CPU 사용량
system.diagnostics.telemetry=텔레메트리

# Telemetry export
telemetry.export.csv=CSV 내보내기
telemetry.export.dialog.title=텔레메트리를 ZIP으로 내보내기
telemetry.export.error.title=내보내기 실패
telemetry.export.error.message=텔레메트리 데이터 내보내기 실패: {0}

# Telemetry
telemetry.reset=텔레메트리 데이터 재설정
telemetry.title=📊 세션 지표
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,12 @@ system.diagnostics.jvm.memory=Memória JVM (usada / total)
system.diagnostics.cpu.usage=Uso da CPU
system.diagnostics.telemetry=Telemetria

# Telemetry export
telemetry.export.csv=Exportar CSV
telemetry.export.dialog.title=Exportar Telemetria como ZIP
telemetry.export.error.title=Falha na Exportação
telemetry.export.error.message=Falha ao exportar dados de telemetria: {0}

# Telemetry
telemetry.reset=Redefinir dados de telemetria
telemetry.title=📊 Métricas da sessão
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=Bộ nhớ JVM (đã dùng / tổng)
system.diagnostics.cpu.usage=Mức sử dụng CPU
system.diagnostics.telemetry=Đo từ xa

# Telemetry export
telemetry.export.csv=Xuất CSV
telemetry.export.dialog.title=Xuất Dữ Liệu Đo Lường thành ZIP
telemetry.export.error.title=Xuất Thất Bại
telemetry.export.error.message=Không thể xuất dữ liệu đo lường: {0}

# Telemetry
telemetry.reset=Đặt lại dữ liệu telemetry
telemetry.title=📊 Chỉ số phiên làm việc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM 内存 (已用 / 总计)
system.diagnostics.cpu.usage=CPU 使用率
system.diagnostics.telemetry=遥测

# Telemetry export
telemetry.export.csv=导出 CSV
telemetry.export.dialog.title=将遥测数据导出为 ZIP
telemetry.export.error.title=导出失败
telemetry.export.error.message=导出遥测数据失败:{0}

# Telemetry
telemetry.reset=重置遥测数据
telemetry.title=📊 会话指标
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM 記憶體 (已使用 / 總計)
system.diagnostics.cpu.usage=CPU 使用率
system.diagnostics.telemetry=遙測

# Telemetry export
telemetry.export.csv=匯出 CSV
telemetry.export.dialog.title=將遙測資料匯出為 ZIP
telemetry.export.error.title=匯出失敗
telemetry.export.error.message=無法匯出遙測資料:{0}

# Telemetry
telemetry.reset=重設遙測資料
telemetry.title=📊 工作階段指標
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/main/kotlin/io/askimo/desktop/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import io.askimo.ui.onboarding.onboardingWizardDialog
import io.askimo.ui.plan.PlansViewModel
import io.askimo.ui.plan.planDetailView
import io.askimo.ui.plan.plansGalleryView
import io.askimo.ui.service.TelemetryExportService
import io.askimo.ui.session.SessionManager
import io.askimo.ui.session.SessionsViewModel
import io.askimo.ui.session.command.DeleteSessionFromProjectCommand
Expand Down Expand Up @@ -1404,6 +1405,30 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? =
val metrics by appContext.telemetry.metricsFlow.collectAsState()
systemResourcesDialog(
onDismiss = { showSystemDiagnosticsDialog = false },
onExportTelemetry = {
scope.launch {
val timestamp = withContext(Dispatchers.IO) {
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
}
val targetFile = FileDialogUtils.pickSavePath(
suggestedName = "telemetry_$timestamp",
extension = "zip",
title = LocalizationManager.getString("telemetry.export.dialog.title"),
) ?: return@launch

val result = TelemetryExportService.export(metrics, targetFile)
if (result.isFailure) {
errorDialogState = ErrorDialogState(
show = true,
title = LocalizationManager.getString("telemetry.export.error.title"),
message = LocalizationManager.getString(
"telemetry.export.error.message",
result.exceptionOrNull()?.message ?: "Unknown error",
),
)
}
}
},
telemetryContent = { telemetryPanel(metrics = metrics, maxHeight = 480.dp) },
)
}
Expand Down
Loading
Loading