mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Add settings preference metrics
This commit is contained in:
1 parent
8f3ae3647b
commit
ffcbac9bf5
16 files changed
+594
-34
No files matched your search
@@ -318,6 +318,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
|
||||
if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
|
||||
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
a.recordSettingsMetricsSnapshot(cfg)
|
||||
}
|
||||
|
||||
go a.restoreOrBuildTabs()
|
||||
|
||||
@@ -889,7 +889,7 @@ export interface SettingsView {
|
||||
statusBarItems: string[]; // ordered visible status bar item ids
|
||||
checkUpdates: boolean; // check for new versions on startup
|
||||
telemetry: boolean; // anonymous launch ping (install id + version + OS)
|
||||
metrics: boolean; // opt-in aggregate agent metrics (anonymous signal/bucket counts)
|
||||
metrics: boolean; // opt-in aggregate desktop metrics (anonymous signal/bucket counts)
|
||||
configPath: string;
|
||||
providerKinds: string[]; // provider implementations the kernel registered (for the kind picker)
|
||||
autoApproveTools: boolean;
|
||||
|
||||
@@ -1421,7 +1421,7 @@ export const en = {
|
||||
"settings.telemetryLabel": "Anonymous usage ping",
|
||||
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
|
||||
"settings.metricsLabel": "Share aggregate quality metrics",
|
||||
"settings.metricsHint": "Off by default. When on, send anonymous counts of how turns end (finish reason, cache-hit rate, error and tool-failure categories) so issues can be spotted across versions. Only enumerated counters — never conversations, prompts, keys, paths, or any text.",
|
||||
"settings.metricsHint": "Off by default. When on, send anonymous turn-end counts and settings preference snapshots (such as desktop style, theme, provider/model identifiers, client version, and bot toggles) plus the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, or file contents.",
|
||||
"updater.currentVersion": "Current version: {v}",
|
||||
"updater.checkButton": "Check for updates",
|
||||
"updater.checking": "Checking for updates…",
|
||||
|
||||
@@ -1538,7 +1538,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名啟動統計",
|
||||
"settings.telemetryHint": "啟動時傳送隨機安裝 ID、版本號和作業系統用於統計活躍安裝量;若上次執行發生崩潰,則在本次啟動補發該崩潰報告。絕不包含對話、金鑰或檔案內容。",
|
||||
"settings.metricsLabel": "共享聚合品質指標",
|
||||
"settings.metricsHint": "預設關閉。開啟後傳送匿名的輪次結束統計(結束原因、快取命中率、錯誤與工具失敗的類別),用於跨版本發現問題。只含列舉化計數——絕不包含對話、提示詞、金鑰、路徑或任何文字。",
|
||||
"settings.metricsHint": "預設關閉。開啟後傳送匿名的輪次結束統計和設定偏好快照(如桌面風格、主題、Provider/模型標識、客戶端版本與機器人開關),以及用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL 或檔案內容。",
|
||||
"context.windowTitle": "上下文視窗",
|
||||
"context.windowSubtitle": "當前上下文視窗佔用",
|
||||
"context.runtimeMetrics": "執行指標",
|
||||
|
||||
@@ -1423,7 +1423,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名启动统计",
|
||||
"settings.telemetryHint": "启动时发送随机安装 ID、版本号和操作系统用于统计活跃安装量;若上次运行发生崩溃,则在本次启动补发该崩溃报告。绝不包含对话、密钥或文件内容。",
|
||||
"settings.metricsLabel": "共享聚合质量指标",
|
||||
"settings.metricsHint": "默认关闭。开启后发送匿名的轮次结束统计(结束原因、缓存命中率、错误与工具失败的类别),用于跨版本发现问题。只含枚举化计数——绝不包含对话、提示词、密钥、路径或任何文本。",
|
||||
"settings.metricsHint": "默认关闭。开启后发送匿名的轮次结束统计和设置偏好快照(如桌面风格、主题、Provider/模型标识、客户端版本与机器人开关),以及用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL 或文件内容。",
|
||||
"updater.currentVersion": "当前版本:{v}",
|
||||
"updater.checkButton": "检查更新",
|
||||
"updater.checking": "正在检查更新…",
|
||||
|
||||
+231
-8
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -15,10 +17,12 @@ import (
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// metrics_app.go is the opt-in aggregate agent-metrics flush: anonymous
|
||||
// (signal, bucket) counters observed from the event stream, POSTed once per
|
||||
// launch. Never carries content, keys, prompts, or paths — only enumerated
|
||||
// integer counts. Gated on config desktop.metrics (default off), dev-skipped.
|
||||
// metrics_app.go is the opt-in aggregate desktop-metrics flush: anonymous
|
||||
// (signal, bucket) counters observed from the event stream and safe desktop
|
||||
// preference snapshots, POSTed once per launch. Never carries content, keys,
|
||||
// prompts, paths, or base URLs; custom provider/model identifiers are normalized
|
||||
// into bounded buckets. Gated on config desktop.metrics (default off),
|
||||
// dev-skipped.
|
||||
|
||||
var metricsEndpoint = "https://crash.reasonix.io/v1/metrics"
|
||||
|
||||
@@ -61,6 +65,220 @@ func (m *metricsAggregator) inc(signal, bucket string) {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func boolBucket(v bool) string {
|
||||
if v {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
func statusBarItemsCountBucket(n int) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
return "n_" + strconv.Itoa(n)
|
||||
}
|
||||
|
||||
func countBucket(n int) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
switch {
|
||||
case n == 0:
|
||||
return "n_0"
|
||||
case n == 1:
|
||||
return "n_1"
|
||||
case n <= 3:
|
||||
return "n_2_3"
|
||||
case n <= 5:
|
||||
return "n_4_5"
|
||||
default:
|
||||
return "n_6_plus"
|
||||
}
|
||||
}
|
||||
|
||||
func knownBucket(value string, allowed ...string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
for _, ok := range allowed {
|
||||
if value == ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func knownBucketDefault(value, def string, allowed ...string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
value = def
|
||||
}
|
||||
return knownBucket(value, allowed...)
|
||||
}
|
||||
|
||||
func metricBucket(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "default"
|
||||
}
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range value {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if ok {
|
||||
b.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "_")
|
||||
if out == "" {
|
||||
return "other"
|
||||
}
|
||||
if len(out) > 96 {
|
||||
return out[:96]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func metricsOfficialProviderHost(baseURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func officialProviderBucket(e *config.ProviderEntry) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
switch config.CanonicalDesktopOfficialProviderName(e.Name) {
|
||||
case "deepseek":
|
||||
if metricsOfficialProviderHost(e.BaseURL) == "api.deepseek.com" {
|
||||
return "deepseek"
|
||||
}
|
||||
case "mimo-api":
|
||||
if metricsOfficialProviderHost(e.BaseURL) == "api.xiaomimimo.com" {
|
||||
return "mimoapi"
|
||||
}
|
||||
case "mimo-token-plan":
|
||||
if metricsOfficialProviderHost(e.BaseURL) == "token-plan-cn.xiaomimimo.com" {
|
||||
return "mimoplan"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerMetricsBucket(e *config.ProviderEntry) string {
|
||||
if b := officialProviderBucket(e); b != "" {
|
||||
return b
|
||||
}
|
||||
if e == nil {
|
||||
return "unknown"
|
||||
}
|
||||
return metricBucket("custom_" + e.Name)
|
||||
}
|
||||
|
||||
func safeModelBucket(c *config.Config, ref string) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
ref = c.DefaultModel
|
||||
}
|
||||
e, ok := c.ResolveModel(ref)
|
||||
if !ok {
|
||||
return "unresolved"
|
||||
}
|
||||
provider := providerMetricsBucket(e)
|
||||
return metricBucket(provider + "_" + e.Model)
|
||||
}
|
||||
|
||||
func plannerModelBucket(c *config.Config) string {
|
||||
if strings.TrimSpace(c.Agent.PlannerModel) == "" {
|
||||
return "off"
|
||||
}
|
||||
return safeModelBucket(c, c.Agent.PlannerModel)
|
||||
}
|
||||
|
||||
func safeProviderAccessBucket(c *config.Config, name string) string {
|
||||
if p, ok := c.Provider(name); ok {
|
||||
return providerMetricsBucket(p)
|
||||
}
|
||||
return metricBucket("custom_" + name)
|
||||
}
|
||||
|
||||
func (m *metricsAggregator) observeSettingsSnapshot(c *config.Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
lang := c.DesktopLanguage()
|
||||
if lang == "" {
|
||||
lang = "auto"
|
||||
}
|
||||
themeStyle := c.DesktopThemeStyle()
|
||||
if themeStyle == "" {
|
||||
themeStyle = "default"
|
||||
}
|
||||
m.inc("settings_language", lang)
|
||||
m.inc("client_surface", "desktop")
|
||||
m.inc("client_version", metricBucket(version))
|
||||
m.inc("settings_desktop_layout", c.DesktopLayoutStyle())
|
||||
m.inc("settings_theme", c.DesktopTheme())
|
||||
m.inc("settings_theme_style", themeStyle)
|
||||
m.inc("settings_close_behavior", c.DesktopCloseBehavior())
|
||||
m.inc("settings_display_mode", c.DesktopDisplayMode())
|
||||
m.inc("settings_auto_plan", desktopAutoPlanMode(c.Agent.AutoPlan))
|
||||
m.inc("settings_status_bar_style", c.DesktopStatusBarStyle())
|
||||
m.inc("settings_status_bar_items_count", statusBarItemsCountBucket(len(c.DesktopStatusBarItems())))
|
||||
m.inc("settings_check_updates", boolBucket(c.DesktopCheckUpdates()))
|
||||
m.inc("settings_default_model", safeModelBucket(c, c.DefaultModel))
|
||||
m.inc("settings_planner_model", plannerModelBucket(c))
|
||||
m.inc("settings_subagent_model", safeModelBucket(c, c.Agent.SubagentModel))
|
||||
m.inc("settings_subagent_effort", knownBucketDefault(c.Agent.SubagentEffort, "auto", "auto", "low", "medium", "high", "xhigh", "max", "off"))
|
||||
m.inc("settings_reasoning_language", config.NormalizeReasoningLanguage(c.Agent.ReasoningLanguage))
|
||||
m.inc("settings_provider_count", countBucket(len(c.Providers)))
|
||||
m.inc("settings_provider_access_count", countBucket(len(c.Desktop.ProviderAccess)))
|
||||
for _, name := range c.Desktop.ProviderAccess {
|
||||
m.inc("settings_provider_access", safeProviderAccessBucket(c, name))
|
||||
}
|
||||
m.observeBotSettingsSnapshot(c)
|
||||
}
|
||||
|
||||
func (m *metricsAggregator) observeBotSettingsSnapshot(c *config.Config) {
|
||||
bot := c.Bot
|
||||
m.inc("settings_bot_enabled", boolBucket(bot.Enabled))
|
||||
m.inc("settings_bot_model", safeModelBucket(c, bot.Model))
|
||||
m.inc("settings_bot_tool_approval", knownBucketDefault(bot.ToolApprovalMode, "ask", "ask", "auto", "yolo"))
|
||||
m.inc("settings_bot_allowlist", boolBucket(bot.Allowlist.Enabled))
|
||||
m.inc("settings_bot_allow_all", boolBucket(bot.Allowlist.AllowAll))
|
||||
m.inc("settings_bot_qq_enabled", boolBucket(bot.QQ.Enabled))
|
||||
m.inc("settings_bot_feishu_enabled", boolBucket(bot.Feishu.Enabled))
|
||||
m.inc("settings_bot_weixin_enabled", boolBucket(bot.Weixin.Enabled))
|
||||
m.inc("settings_bot_connection_count", countBucket(len(bot.Connections)))
|
||||
for _, conn := range bot.Connections {
|
||||
provider := knownBucket(conn.Provider, "qq", "feishu", "weixin")
|
||||
m.inc("settings_bot_connection_provider", provider)
|
||||
m.inc("settings_bot_connection_enabled", boolBucket(conn.Enabled))
|
||||
m.inc("settings_bot_connection_status", knownBucket(conn.Status, "disconnected", "pending", "connected", "error"))
|
||||
m.inc("settings_bot_connection_model", safeModelBucket(c, conn.Model))
|
||||
m.inc("settings_bot_connection_approval", knownBucketDefault(conn.ToolApprovalMode, "default", "default", "ask", "auto", "yolo"))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordSettingsMetricsSnapshot(c *config.Config) {
|
||||
if version == "dev" || c == nil {
|
||||
return
|
||||
}
|
||||
m := a.metrics.Load()
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.observeSettingsSnapshot(c)
|
||||
m.persist()
|
||||
}
|
||||
|
||||
// observe maps one event to counter increments, reading only enumerated facts
|
||||
// (finish reason, error class, cache-hit bucket) — never message text.
|
||||
func (m *metricsAggregator) observe(e event.Event) {
|
||||
@@ -193,9 +411,10 @@ type metricCounter struct {
|
||||
}
|
||||
|
||||
type metricsPayload struct {
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Counters []metricCounter `json:"counters"`
|
||||
InstallID string `json:"installId,omitempty"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Counters []metricCounter `json:"counters"`
|
||||
}
|
||||
|
||||
func flatten(c counters) []metricCounter {
|
||||
@@ -227,7 +446,11 @@ func (a *App) flushMetrics() {
|
||||
return // nothing pending
|
||||
}
|
||||
flat := flatten(readCounters(temp))
|
||||
if len(flat) == 0 || a.postMetrics(metricsPayload{Version: version, OS: runtime.GOOS, Counters: flat}) {
|
||||
payload := metricsPayload{Version: version, OS: runtime.GOOS, Counters: flat}
|
||||
if id, err := installID(); err == nil {
|
||||
payload.InstallID = id
|
||||
}
|
||||
if len(flat) == 0 || a.postMetrics(payload) {
|
||||
_ = os.Remove(temp)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
@@ -52,6 +53,101 @@ func TestObserveReadsNoMessageText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveSettingsSnapshotUsesSafeBuckets(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
if err := cfg.SetDesktopLanguage(""); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopLayoutStyle("workbench"); err != nil {
|
||||
t.Fatalf("SetDesktopLayoutStyle: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("SetDesktopAppearance: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopCloseBehavior("quit"); err != nil {
|
||||
t.Fatalf("SetDesktopCloseBehavior: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopDisplayMode("compact"); err != nil {
|
||||
t.Fatalf("SetDesktopDisplayMode: %v", err)
|
||||
}
|
||||
if err := cfg.SetAutoPlan("on"); err != nil {
|
||||
t.Fatalf("SetAutoPlan: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopStatusBarStyle("icon"); err != nil {
|
||||
t.Fatalf("SetDesktopStatusBarStyle: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopStatusBarItems([]string{"model", "cache", "balance"}); err != nil {
|
||||
t.Fatalf("SetDesktopStatusBarItems: %v", err)
|
||||
}
|
||||
if err := cfg.SetDesktopCheckUpdates(false); err != nil {
|
||||
t.Fatalf("SetDesktopCheckUpdates: %v", err)
|
||||
}
|
||||
customProvider := "Local OpenAI"
|
||||
customModel := "Qwen-72B-Instruct.private"
|
||||
cfg.Providers = append(cfg.Providers, config.ProviderEntry{
|
||||
Name: customProvider,
|
||||
Kind: "openai",
|
||||
BaseURL: "http://127.0.0.1:9999/v1",
|
||||
Models: []string{customModel},
|
||||
Default: customModel,
|
||||
})
|
||||
cfg.Agent.PlannerModel = customProvider + "/" + customModel
|
||||
cfg.Desktop.ProviderAccess = []string{customProvider}
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
Provider: "feishu",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Model: customProvider + "/" + customModel,
|
||||
}}
|
||||
|
||||
m := newMetricsAggregator(t.TempDir())
|
||||
m.observeSettingsSnapshot(cfg)
|
||||
|
||||
want := map[string]string{
|
||||
"settings_language": "auto",
|
||||
"client_surface": "desktop",
|
||||
"client_version": metricBucket(version),
|
||||
"settings_desktop_layout": "workbench",
|
||||
"settings_theme": "dark",
|
||||
"settings_theme_style": "graphite",
|
||||
"settings_close_behavior": "quit",
|
||||
"settings_display_mode": "compact",
|
||||
"settings_auto_plan": "on",
|
||||
"settings_status_bar_style": "icon",
|
||||
"settings_status_bar_items_count": "n_3",
|
||||
"settings_check_updates": "off",
|
||||
"settings_default_model": "deepseek_deepseek_v4_flash",
|
||||
"settings_planner_model": metricBucket("custom_" + customProvider + "_" + customModel),
|
||||
"settings_provider_access": metricBucket("custom_" + customProvider),
|
||||
"settings_bot_enabled": "off",
|
||||
"settings_bot_connection_count": "n_1",
|
||||
"settings_bot_connection_provider": "feishu",
|
||||
"settings_bot_connection_enabled": "on",
|
||||
"settings_bot_connection_status": "connected",
|
||||
"settings_bot_connection_model": metricBucket("custom_" + customProvider + "_" + customModel),
|
||||
}
|
||||
for signal, bucket := range want {
|
||||
if got := m.c[signal][bucket]; got != 1 {
|
||||
t.Errorf("%s/%s = %d, want 1", signal, bucket, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveSettingsSnapshotCountsDisabledPlannerAsOff(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Agent.PlannerModel = ""
|
||||
|
||||
m := newMetricsAggregator(t.TempDir())
|
||||
m.observeSettingsSnapshot(cfg)
|
||||
|
||||
if got := m.c["settings_planner_model"]["off"]; got != 1 {
|
||||
t.Fatalf("settings_planner_model/off = %d, want 1", got)
|
||||
}
|
||||
if got := m.c["settings_planner_model"][safeModelBucket(cfg, cfg.DefaultModel)]; got != 0 {
|
||||
t.Fatalf("disabled planner should not count the default model, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorClass(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"deepseek: status 400: bad": "http_400",
|
||||
|
||||
@@ -1519,7 +1519,7 @@ func (a *App) SetDesktopTelemetry(enabled bool) error {
|
||||
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopTelemetry(enabled) })
|
||||
}
|
||||
|
||||
// SetDesktopMetrics sets whether the desktop sends opt-in aggregate agent metrics,
|
||||
// SetDesktopMetrics sets whether the desktop sends opt-in aggregate desktop metrics,
|
||||
// starting or stopping the live aggregator so the toggle takes effect immediately.
|
||||
func (a *App) SetDesktopMetrics(enabled bool) error {
|
||||
if err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopMetrics(enabled) }); err != nil {
|
||||
@@ -1528,6 +1528,9 @@ func (a *App) SetDesktopMetrics(enabled bool) error {
|
||||
switch {
|
||||
case enabled && a.metrics.Load() == nil && version != "dev":
|
||||
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
a.recordSettingsMetricsSnapshot(cfg)
|
||||
}
|
||||
case !enabled:
|
||||
a.metrics.Store(nil)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ type DesktopConfig struct {
|
||||
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
|
||||
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
|
||||
Telemetry *bool `toml:"telemetry"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled
|
||||
Metrics *bool `toml:"metrics"` // opt-in aggregate agent metrics (anonymous signal/bucket counts; no content); nil = disabled
|
||||
Metrics *bool `toml:"metrics"` // opt-in aggregate desktop metrics (anonymous signal/bucket counts; no content); nil = disabled
|
||||
ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access
|
||||
ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func (c *Config) DesktopTelemetry() bool {
|
||||
return *c.Desktop.Telemetry
|
||||
}
|
||||
|
||||
// DesktopMetrics reports whether the desktop sends opt-in aggregate agent
|
||||
// DesktopMetrics reports whether the desktop sends opt-in aggregate desktop
|
||||
// metrics — anonymous (signal, bucket) counters, never content. Default off.
|
||||
func (c *Config) DesktopMetrics() bool {
|
||||
if c == nil || c.Desktop.Metrics == nil {
|
||||
|
||||
@@ -293,7 +293,7 @@ func (c *Config) SetDesktopTelemetry(enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDesktopMetrics sets whether the desktop sends opt-in aggregate agent metrics.
|
||||
// SetDesktopMetrics sets whether the desktop sends opt-in aggregate desktop metrics.
|
||||
func (c *Config) SetDesktopMetrics(enabled bool) error {
|
||||
c.Desktop.Metrics = &enabled
|
||||
return nil
|
||||
|
||||
@@ -103,7 +103,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
fmt.Fprintf(&b, "status_bar_items = %s # desktop: ordered visible bottom status bar items\n", renderStringArray(c.DesktopStatusBarItems()))
|
||||
fmt.Fprintf(&b, "check_updates = %v # desktop: check for new versions on startup\n", c.DesktopCheckUpdates())
|
||||
fmt.Fprintf(&b, "telemetry = %v # desktop: anonymous launch ping (install id + version + OS); never content\n", c.DesktopTelemetry())
|
||||
fmt.Fprintf(&b, "metrics = %v # desktop: opt-in aggregate agent metrics (anonymous signal/bucket counts); never content\n", c.DesktopMetrics())
|
||||
fmt.Fprintf(&b, "metrics = %v # desktop: opt-in aggregate desktop metrics (anonymous signal/bucket counts); never content\n", c.DesktopMetrics())
|
||||
if len(c.Desktop.ProviderAccess) > 0 {
|
||||
fmt.Fprintf(&b, "provider_access = %s # desktop settings: providers shown on Settings > Model > Access\n", renderStringArray(c.Desktop.ProviderAccess))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Apply once:
|
||||
-- wrangler d1 execute reasonix-crash --remote --file=migrate-metric-users.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_users (
|
||||
date TEXT NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
PRIMARY KEY (date, signal, bucket, install_id)
|
||||
);
|
||||
@@ -63,8 +63,8 @@ CREATE TABLE IF NOT EXISTS pings (
|
||||
PRIMARY KEY (date, install_id)
|
||||
);
|
||||
|
||||
-- Opt-in aggregate agent metrics: anonymous per-day (signal, bucket) counters,
|
||||
-- no install id and no content. Generic shape so a new signal is just new rows.
|
||||
-- Opt-in aggregate desktop metrics: anonymous per-day (signal, bucket) counters,
|
||||
-- no content. Generic shape so a new signal is just new rows.
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
date TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
@@ -75,6 +75,18 @@ CREATE TABLE IF NOT EXISTS metrics (
|
||||
PRIMARY KEY (date, version, os, signal, bucket)
|
||||
);
|
||||
|
||||
-- Deduplicated DAU for opt-in metric buckets. install_id is the same random
|
||||
-- anonymous desktop install id used by launch pings; it is not an account id.
|
||||
CREATE TABLE IF NOT EXISTS metric_users (
|
||||
date TEXT NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
PRIMARY KEY (date, signal, bucket, install_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
|
||||
@@ -76,9 +76,9 @@ const Ping = z.object({
|
||||
osVersion: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
// Opt-in aggregate agent metrics: a per-launch snapshot of (signal, bucket)
|
||||
// counters. No install id, no content — just enumerated signals so the worker
|
||||
// table can never be polluted with arbitrary keys.
|
||||
// Opt-in aggregate desktop metrics: a per-launch snapshot of (signal, bucket)
|
||||
// counters. No install id, no content — just enumerated signals and bounded
|
||||
// buckets so the worker table can never be polluted with arbitrary keys.
|
||||
const METRIC_SIGNALS = [
|
||||
"finish_reason",
|
||||
"empty_final",
|
||||
@@ -87,9 +87,47 @@ const METRIC_SIGNALS = [
|
||||
"tool_error",
|
||||
"compaction",
|
||||
"turns",
|
||||
"client_surface",
|
||||
"client_version",
|
||||
"settings_language",
|
||||
"settings_desktop_layout",
|
||||
"settings_theme",
|
||||
"settings_theme_style",
|
||||
"settings_close_behavior",
|
||||
"settings_display_mode",
|
||||
"settings_auto_plan",
|
||||
"settings_status_bar_style",
|
||||
"settings_status_bar_items_count",
|
||||
"settings_check_updates",
|
||||
"settings_default_model",
|
||||
"settings_planner_model",
|
||||
"settings_subagent_model",
|
||||
"settings_subagent_effort",
|
||||
"settings_reasoning_language",
|
||||
"settings_provider_count",
|
||||
"settings_provider_access_count",
|
||||
"settings_provider_access",
|
||||
"settings_bot_enabled",
|
||||
"settings_bot_model",
|
||||
"settings_bot_tool_approval",
|
||||
"settings_bot_allowlist",
|
||||
"settings_bot_allow_all",
|
||||
"settings_bot_qq_enabled",
|
||||
"settings_bot_feishu_enabled",
|
||||
"settings_bot_weixin_enabled",
|
||||
"settings_bot_connection_count",
|
||||
"settings_bot_connection_provider",
|
||||
"settings_bot_connection_enabled",
|
||||
"settings_bot_connection_status",
|
||||
"settings_bot_connection_model",
|
||||
"settings_bot_connection_approval",
|
||||
] as const;
|
||||
|
||||
const Metrics = z.object({
|
||||
installId: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{32}$/)
|
||||
.optional(),
|
||||
version: z.string().min(1).max(64),
|
||||
os: z.string().min(1).max(32),
|
||||
counters: z
|
||||
@@ -99,13 +137,13 @@ const Metrics = z.object({
|
||||
bucket: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.max(96)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
count: z.number().int().min(1).max(1_000_000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(64),
|
||||
.max(128),
|
||||
});
|
||||
|
||||
type FingerprintInput = {
|
||||
@@ -392,6 +430,19 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
|
||||
count = count + ?5`,
|
||||
);
|
||||
await env.DB.batch(m.counters.map((c) => upsert.bind(m.version, m.os, c.signal, c.bucket, c.count)));
|
||||
if (m.installId) {
|
||||
const userUpsert = env.DB.prepare(
|
||||
`INSERT INTO metric_users (date, version, os, signal, bucket, install_id)
|
||||
VALUES (date('now'), ?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT (date, signal, bucket, install_id) DO UPDATE SET
|
||||
version = ?1, os = ?2`,
|
||||
);
|
||||
try {
|
||||
await env.DB.batch(m.counters.map((c) => userUpsert.bind(m.version, m.os, c.signal, c.bucket, m.installId)));
|
||||
} catch (err) {
|
||||
console.warn("metric_users write failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("ok", { status: 202 });
|
||||
}
|
||||
@@ -590,11 +641,23 @@ async function latestObservedVersion(env: Env): Promise<string> {
|
||||
return newestReleaseVersion(rows.results.map((r) => r.version));
|
||||
}
|
||||
|
||||
async function metricUserRows(env: Env): Promise<{ signal: string; bucket: string; total: number }[]> {
|
||||
try {
|
||||
const rows = await env.DB.prepare(
|
||||
"SELECT signal, bucket, COUNT(*) AS total FROM metric_users WHERE date >= date('now', '-6 day') GROUP BY signal, bucket ORDER BY signal, total DESC",
|
||||
).all<{ signal: string; bucket: string; total: number }>();
|
||||
return rows.results;
|
||||
} catch (err) {
|
||||
console.warn("metric_users query failed", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStats(request: Request, env: Env, user: User): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const filters = statsFilters(url);
|
||||
const latestVersion = await latestObservedVersion(env);
|
||||
const [daily, versions, platforms, crashes, metrics, sources] = await Promise.all([
|
||||
const [daily, versions, platforms, crashes, metrics, metricUsers, sources] = await Promise.all([
|
||||
env.DB.prepare(
|
||||
"SELECT date, COUNT(*) AS users, SUM(opens) AS opens FROM pings WHERE date >= date('now', '-29 day') GROUP BY date",
|
||||
).all<{ date: string; users: number; opens: number }>(),
|
||||
@@ -608,6 +671,7 @@ async function handleStats(request: Request, env: Env, user: User): Promise<Resp
|
||||
env.DB.prepare(
|
||||
"SELECT signal, bucket, SUM(count) AS total FROM metrics WHERE date >= date('now', '-6 day') GROUP BY signal, bucket ORDER BY signal, total DESC",
|
||||
).all<{ signal: string; bucket: string; total: number }>(),
|
||||
metricUserRows(env),
|
||||
env.DB.prepare("SELECT source AS label, COUNT(*) AS users FROM groups GROUP BY source ORDER BY users DESC").all<{ label: string; users: number }>(),
|
||||
]);
|
||||
return html(
|
||||
@@ -618,6 +682,7 @@ async function handleStats(request: Request, env: Env, user: User): Promise<Resp
|
||||
platforms: platforms.results,
|
||||
crashes: crashes.results,
|
||||
metrics: metrics.results,
|
||||
metricUsers,
|
||||
sources: sources.results,
|
||||
latestVersion,
|
||||
filters,
|
||||
|
||||
@@ -68,6 +68,11 @@ pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px
|
||||
font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px}
|
||||
.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px}
|
||||
.preference-dashboard{display:flex;flex-direction:column;gap:18px}
|
||||
.pref-section{border:1px solid var(--line);border-radius:18px;background:var(--bg-soft);padding:16px 18px}
|
||||
.pref-section>h3{font-size:13px;font-weight:700;color:var(--ink);margin:0 0 10px}
|
||||
.pref-section .metric-block h3{color:var(--ink-3)}
|
||||
.pref-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.filter-card{padding-top:22px}
|
||||
.filter-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}
|
||||
.filter-head h2{margin:0}
|
||||
@@ -181,7 +186,7 @@ td select{width:auto;padding:6px 10px;border-radius:10px}
|
||||
@media(max-width:900px){.facet-grid{grid-template-columns:1fr}.crash-head{display:none}.crash-item{grid-template-columns:1fr;gap:10px}
|
||||
.crash-health{justify-content:flex-start}.crash-count{text-align:left}.filter-head{align-items:flex-start;flex-direction:column;gap:6px}
|
||||
.group-metrics{grid-template-columns:1fr 1fr}.sample summary{grid-template-columns:1fr}.sample-time{text-align:left}.manage-head{flex-direction:column}.manage-actions{justify-content:flex-start}.manage-grid{grid-template-columns:1fr}.manage-form.wide{grid-column:auto}}
|
||||
@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
|
||||
@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics,.pref-metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
|
||||
`;
|
||||
|
||||
const LANG_SWITCH = `<span class="lang-switch" role="group" aria-label="Language"><button type="button" data-lang-option="zh">中文</button><button type="button" data-lang-option="en">EN</button></span>`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { esc, page } from "./shell";
|
||||
import { type User, userNav } from "./auth";
|
||||
|
||||
type Daily = { date: string; users: number; opens: number };
|
||||
type MetricRow = { signal: string; bucket: string; total: number };
|
||||
|
||||
function last30Days(rows: Daily[]): Daily[] {
|
||||
const byDate = new Map(rows.map((r) => [r.date, r]));
|
||||
@@ -85,18 +86,154 @@ function listBars(rows: { label: string; users: number }[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function metricsCards(rows: { signal: string; bucket: string; total: number }[]): string {
|
||||
if (!rows.length)
|
||||
return `<div class="empty">${i18n("No metrics yet — flows in once an opt-in build ships", "暂无运行指标 — 等 opt-in 版本发布后有数据")}</div>`;
|
||||
const METRIC_SIGNAL_LABELS: Record<string, { en: string; zh: string }> = {
|
||||
finish_reason: { en: "Finish reason", zh: "结束原因" },
|
||||
empty_final: { en: "Empty final guard", zh: "空回复拦截" },
|
||||
provider_error: { en: "Provider errors", zh: "Provider 错误" },
|
||||
cache_hit: { en: "Cache hit rate", zh: "缓存命中率" },
|
||||
tool_error: { en: "Tool errors", zh: "工具错误" },
|
||||
compaction: { en: "Compactions", zh: "压缩" },
|
||||
turns: { en: "Turns", zh: "轮次" },
|
||||
client_surface: { en: "Client surface", zh: "客户端形态" },
|
||||
client_version: { en: "Client version", zh: "客户端版本" },
|
||||
settings_language: { en: "Settings: language", zh: "设置:语言" },
|
||||
settings_desktop_layout: { en: "Settings: desktop style", zh: "设置:桌面风格" },
|
||||
settings_theme: { en: "Settings: light/dark", zh: "设置:深浅模式" },
|
||||
settings_theme_style: { en: "Settings: theme style", zh: "设置:主题" },
|
||||
settings_close_behavior: { en: "Settings: close behavior", zh: "设置:关闭行为" },
|
||||
settings_display_mode: { en: "Settings: transcript mode", zh: "设置:会话展示" },
|
||||
settings_auto_plan: { en: "Settings: auto plan", zh: "设置:自动计划" },
|
||||
settings_status_bar_style: { en: "Settings: status bar style", zh: "设置:信息栏样式" },
|
||||
settings_status_bar_items_count: { en: "Settings: status bar items", zh: "设置:信息栏项数" },
|
||||
settings_check_updates: { en: "Settings: update checks", zh: "设置:更新检查" },
|
||||
settings_default_model: { en: "Settings: default model", zh: "设置:默认模型" },
|
||||
settings_planner_model: { en: "Settings: planner model", zh: "设置:规划模型" },
|
||||
settings_subagent_model: { en: "Settings: subagent model", zh: "设置:子代理模型" },
|
||||
settings_subagent_effort: { en: "Settings: subagent effort", zh: "设置:子代理 effort" },
|
||||
settings_reasoning_language: { en: "Settings: reasoning language", zh: "设置:推理语言" },
|
||||
settings_provider_count: { en: "Settings: provider count", zh: "设置:Provider 数量" },
|
||||
settings_provider_access_count: { en: "Settings: enabled providers", zh: "设置:启用 Provider 数量" },
|
||||
settings_provider_access: { en: "Settings: provider access", zh: "设置:Provider 选择" },
|
||||
settings_bot_enabled: { en: "Bot: enabled", zh: "机器人:总开关" },
|
||||
settings_bot_model: { en: "Bot: default model", zh: "机器人:默认模型" },
|
||||
settings_bot_tool_approval: { en: "Bot: tool approval", zh: "机器人:工具审批" },
|
||||
settings_bot_allowlist: { en: "Bot: allowlist", zh: "机器人:白名单" },
|
||||
settings_bot_allow_all: { en: "Bot: allow all", zh: "机器人:允许所有人" },
|
||||
settings_bot_qq_enabled: { en: "Bot: QQ legacy", zh: "机器人:QQ 旧配置" },
|
||||
settings_bot_feishu_enabled: { en: "Bot: Feishu legacy", zh: "机器人:飞书旧配置" },
|
||||
settings_bot_weixin_enabled: { en: "Bot: Weixin legacy", zh: "机器人:微信旧配置" },
|
||||
settings_bot_connection_count: { en: "Bot: connection count", zh: "机器人:连接数量" },
|
||||
settings_bot_connection_provider: { en: "Bot: connection provider", zh: "机器人:连接渠道" },
|
||||
settings_bot_connection_enabled: { en: "Bot: connection enabled", zh: "机器人:连接开关" },
|
||||
settings_bot_connection_status: { en: "Bot: connection status", zh: "机器人:连接状态" },
|
||||
settings_bot_connection_model: { en: "Bot: connection model", zh: "机器人:连接模型" },
|
||||
settings_bot_connection_approval: { en: "Bot: connection approval", zh: "机器人:连接审批" },
|
||||
};
|
||||
|
||||
const AGENT_METRIC_SIGNALS = ["finish_reason", "empty_final", "provider_error", "cache_hit", "tool_error", "compaction", "turns"];
|
||||
|
||||
const SETTINGS_METRIC_GROUPS: { en: string; zh: string; signals: string[] }[] = [
|
||||
{
|
||||
en: "Client",
|
||||
zh: "客户端",
|
||||
signals: ["client_surface", "client_version", "settings_language"],
|
||||
},
|
||||
{
|
||||
en: "Appearance and layout",
|
||||
zh: "外观与布局",
|
||||
signals: [
|
||||
"settings_desktop_layout",
|
||||
"settings_theme",
|
||||
"settings_theme_style",
|
||||
"settings_display_mode",
|
||||
"settings_status_bar_style",
|
||||
"settings_status_bar_items_count",
|
||||
],
|
||||
},
|
||||
{
|
||||
en: "Models",
|
||||
zh: "模型",
|
||||
signals: [
|
||||
"settings_default_model",
|
||||
"settings_planner_model",
|
||||
"settings_subagent_model",
|
||||
"settings_subagent_effort",
|
||||
"settings_reasoning_language",
|
||||
],
|
||||
},
|
||||
{
|
||||
en: "Providers",
|
||||
zh: "Provider",
|
||||
signals: ["settings_provider_count", "settings_provider_access_count", "settings_provider_access"],
|
||||
},
|
||||
{
|
||||
en: "Behavior toggles",
|
||||
zh: "行为开关",
|
||||
signals: ["settings_close_behavior", "settings_auto_plan", "settings_check_updates"],
|
||||
},
|
||||
{
|
||||
en: "Bots",
|
||||
zh: "机器人",
|
||||
signals: [
|
||||
"settings_bot_enabled",
|
||||
"settings_bot_model",
|
||||
"settings_bot_tool_approval",
|
||||
"settings_bot_allowlist",
|
||||
"settings_bot_allow_all",
|
||||
"settings_bot_qq_enabled",
|
||||
"settings_bot_feishu_enabled",
|
||||
"settings_bot_weixin_enabled",
|
||||
"settings_bot_connection_count",
|
||||
"settings_bot_connection_provider",
|
||||
"settings_bot_connection_enabled",
|
||||
"settings_bot_connection_status",
|
||||
"settings_bot_connection_model",
|
||||
"settings_bot_connection_approval",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function metricSignalLabel(signal: string): string {
|
||||
const label = METRIC_SIGNAL_LABELS[signal];
|
||||
return label ? i18n(label.en, label.zh) : esc(signal);
|
||||
}
|
||||
|
||||
function metricsBySignal(rows: MetricRow[]): Map<string, { label: string; users: number }[]> {
|
||||
const bySignal = new Map<string, { label: string; users: number }[]>();
|
||||
for (const r of rows) {
|
||||
const list = bySignal.get(r.signal) ?? [];
|
||||
list.push({ label: r.bucket, users: r.total });
|
||||
bySignal.set(r.signal, list);
|
||||
}
|
||||
return `<div class="metrics">${[...bySignal.entries()]
|
||||
.map(([signal, list]) => `<div class="metric-block"><h3>${esc(signal)}</h3>${listBars(list)}</div>`)
|
||||
.join("")}</div>`;
|
||||
return bySignal;
|
||||
}
|
||||
|
||||
function metricBlocks(bySignal: Map<string, { label: string; users: number }[]>, signals: string[]): string {
|
||||
return signals
|
||||
.filter((signal) => bySignal.has(signal))
|
||||
.map((signal) => `<div class="metric-block"><h3>${metricSignalLabel(signal)}</h3>${listBars(bySignal.get(signal) ?? [])}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function metricsCards(rows: MetricRow[], signals = AGENT_METRIC_SIGNALS): string {
|
||||
if (!rows.length)
|
||||
return `<div class="empty">${i18n("No metrics yet — flows in once an opt-in build ships", "暂无运行指标 — 等 opt-in 版本发布后有数据")}</div>`;
|
||||
const bySignal = metricsBySignal(rows);
|
||||
const blocks = metricBlocks(bySignal, signals);
|
||||
return blocks ? `<div class="metrics">${blocks}</div>` : `<div class="empty">${i18n("No data in the last 7 days", "近 7 天暂无数据")}</div>`;
|
||||
}
|
||||
|
||||
function settingsDashboard(rows: MetricRow[]): string {
|
||||
const bySignal = metricsBySignal(rows);
|
||||
const sections = SETTINGS_METRIC_GROUPS.map((group) => {
|
||||
const blocks = metricBlocks(bySignal, group.signals);
|
||||
if (!blocks) return "";
|
||||
return `<section class="pref-section"><h3>${i18n(group.en, group.zh)}</h3><div class="metrics pref-metrics">${blocks}</div></section>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
if (!sections) return `<div class="empty">${i18n("No settings preference metrics yet", "暂无设置偏好指标")}</div>`;
|
||||
return `<div class="preference-dashboard">${sections}</div>`;
|
||||
}
|
||||
|
||||
function statusPill(status: string): string {
|
||||
@@ -168,7 +305,8 @@ export function renderStats(
|
||||
versions: { label: string; users: number }[];
|
||||
platforms: { label: string; users: number }[];
|
||||
crashes: CrashRow[];
|
||||
metrics: { signal: string; bucket: string; total: number }[];
|
||||
metrics: MetricRow[];
|
||||
metricUsers: MetricRow[];
|
||||
sources: { label: string; users: number }[];
|
||||
latestVersion: string;
|
||||
filters: { status: string; source: string; version: string; os: string; platform: string; newLatest: boolean; regressed: boolean };
|
||||
@@ -178,6 +316,9 @@ export function renderStats(
|
||||
const days = last30Days(data.daily);
|
||||
const totalUsers = days.at(-1)?.users ?? 0;
|
||||
const anyPing = days.some((d) => d.opens > 0);
|
||||
const agentMetrics = data.metrics.filter((r) => AGENT_METRIC_SIGNALS.includes(r.signal));
|
||||
const settingsMetrics = data.metrics.filter((r) => r.signal === "client_surface" || r.signal === "client_version" || r.signal.startsWith("settings_"));
|
||||
const settingsMetricUsers = data.metricUsers.filter((r) => r.signal === "client_surface" || r.signal === "client_version" || r.signal.startsWith("settings_"));
|
||||
const filterQS = (patch: Record<string, string>) => {
|
||||
const params = new URLSearchParams();
|
||||
const put = (k: string, v: string) => {
|
||||
@@ -227,7 +368,9 @@ ${filterTab("Regressed", "回归", filterQS({ regressed: data.filters.regressed
|
||||
${anyPing ? dailyChart(days) : `<div class="empty">${i18n("No pings yet — data starts flowing once a telemetry-enabled build ships", "暂无启动 ping — 等带统计的版本发布后这里开始有数据")}</div>`}</div>
|
||||
<div class="card"><h2>${i18nHTML("Versions <b>— 7 days</b>", "版本分布 <b>— 7 天</b>")}</h2>${listBars(data.versions)}</div>
|
||||
<div class="card"><h2>${i18nHTML("Platforms <b>— 7 days</b>", "平台分布 <b>— 7 天</b>")}</h2>${listBars(data.platforms)}</div>
|
||||
<div class="card full"><h2>${i18nHTML("Agent signals <b>— 7 days, opt-in aggregate</b>", "运行指标 <b>— 7 天,opt-in 汇总</b>")}</h2>${metricsCards(data.metrics)}</div>
|
||||
<div class="card full"><h2>${i18nHTML("Settings preference DAU <b>— 7 days, deduplicated by install</b>", "设置偏好 DAU <b>— 7 天,按安装去重</b>")}</h2>${settingsDashboard(settingsMetricUsers)}</div>
|
||||
<div class="card full"><h2>${i18nHTML("Settings preference snapshots <b>— 7 days, launch/open counts</b>", "设置偏好快照 <b>— 7 天,启动/开启次数</b>")}</h2>${settingsDashboard(settingsMetrics)}</div>
|
||||
<div class="card full"><h2>${i18nHTML("Agent signals <b>— 7 days, opt-in aggregate</b>", "运行指标 <b>— 7 天,opt-in 汇总</b>")}</h2>${metricsCards(agentMetrics)}</div>
|
||||
${filters}
|
||||
<div class="card full crash-card"><h2>${i18nHTML("Report groups <b>— select a row for stack samples</b>", "诊断分组 <b>— 选择一行查看堆栈样本</b>")}</h2>${reportGroups(data.crashes)}</div>
|
||||
</div>`,
|
||||
|
||||
Reference in new issue
Block a user