客户端错误遥测(#24)此前只有 ingest + 留存清理,无后台查看。新增管理员只读视图 (列表 + 聚合两者),admin-gated —— device_id 可关联账号,仅管理员可读。 后端: - model.ListTelemetryEvents(filter, startIdx, pageSize):分页列表(received_at desc), 按 user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。 - model.AggregateTelemetryEvents(filter, dimension, limit):按白名单维度 group by 计数 + COUNT(DISTINCT user_id),busiest first。维度白名单是 SQL 注入防线(列名只能来自 map)。 跨库安全(仅 COUNT/COUNT(DISTINCT)/GROUP BY 固定列,无 DB 特有日期函数)。 - 查询走 LOG_DB(与 ingest 同库);遥测仍隔离于计费、载荷无用户内容。 - controller AdminListTelemetryEvents / AdminAggregateTelemetryEvents;路由 GET /api/telemetry/{events,aggregate},middleware.AdminAuth() 守门。 前端(web/default): - features/telemetry:api + types + 页面(过滤条 + 聚合面板含维度切换/条形/去重用户数 + 分页列表表)。 - 路由 /_authenticated/telemetry(ROLE.ADMIN 守门),系统设置工作区侧栏加「Client Telemetry」入口。 - routeTree.gen.ts 由 router-generator 重新生成(仅 +22 行 telemetry 路由,无重排)。 测试:model/telemetry_query_test.go 覆盖列表过滤/分页/最新在前、聚合计数 + 去重用户、 维度白名单拒绝(注入防线)。go build ./... + model/controller/router 全绿;前端 tsc 仅 resources-page 一处既有报错(与本改动无关)。 影响面:Manager ✅(新增 admin 只读端点 + 前端页面)/ 计费 ❌(遥测隔离)/ 密钥 ❌ / 审计 ❌ / Client ❌ / agent_swarm ❌ / 发布链路 ❌。隐私:沿用 #24 口径,载荷无用户内容, device_id 账号可关联故管理员才可读。 Refs #24。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
3.6 KiB
Go
91 lines
3.6 KiB
Go
package model
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 管理员遥测查询:列表过滤 + 分页(最新在前)、聚合(按维度计数 + 去重用户数)、维度白名单。
|
|
// TelemetryEvent 由包级 TestMain 迁移;LOG_DB == DB。
|
|
func seedTelemetry(t *testing.T) {
|
|
t.Helper()
|
|
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
|
|
rows := []TelemetryEvent{
|
|
{ReceivedAt: 1000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
|
{ReceivedAt: 2000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ECONNRESET"},
|
|
{ReceivedAt: 3000, UserId: 2, ClientId: "dev-b", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
|
|
{ReceivedAt: 4000, UserId: 3, ClientId: "dev-c", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "crash", ErrorCode: "SIGSEGV"},
|
|
}
|
|
for i := range rows {
|
|
rows[i].SchemaVersion = 1
|
|
require.NoError(t, LOG_DB.Create(&rows[i]).Error)
|
|
}
|
|
}
|
|
|
|
func TestListTelemetryEvents_FilterAndPaging(t *testing.T) {
|
|
seedTelemetry(t)
|
|
|
|
// no filter -> all 4, newest first.
|
|
events, total, err := ListTelemetryEvents(TelemetryQueryFilter{}, 0, 10)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 4, total)
|
|
require.Len(t, events, 4)
|
|
require.EqualValues(t, 4000, events[0].ReceivedAt, "newest first")
|
|
require.EqualValues(t, 1000, events[3].ReceivedAt)
|
|
|
|
// platform filter.
|
|
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{Platform: "windows"}, 0, 10)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 2, total)
|
|
require.Len(t, events, 2)
|
|
|
|
// user + category filter.
|
|
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{UserId: 1, ErrorCategory: "network"}, 0, 10)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 2, total)
|
|
|
|
// time range (received_at in [2000,3000]).
|
|
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{StartReceivedAt: 2000, EndReceivedAt: 3000}, 0, 10)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 2, total)
|
|
|
|
// pagination: page size 2 -> total stays 4, page returns 2.
|
|
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{}, 0, 2)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 4, total)
|
|
require.Len(t, events, 2)
|
|
events2, _, err := ListTelemetryEvents(TelemetryQueryFilter{}, 2, 2)
|
|
require.NoError(t, err)
|
|
require.Len(t, events2, 2)
|
|
require.NotEqual(t, events[0].Id, events2[0].Id, "pages are disjoint")
|
|
}
|
|
|
|
func TestAggregateTelemetryEvents_CountsAndDistinctUsers(t *testing.T) {
|
|
seedTelemetry(t)
|
|
|
|
// by error_category: network=3 (users 1,2 -> 2 distinct), crash=1 (user 3).
|
|
buckets, err := AggregateTelemetryEvents(TelemetryQueryFilter{}, "error_category", 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, buckets, 2)
|
|
require.Equal(t, "network", buckets[0].Key, "busiest first")
|
|
require.EqualValues(t, 3, buckets[0].Count)
|
|
require.EqualValues(t, 2, buckets[0].Users, "distinct accounts")
|
|
require.Equal(t, "crash", buckets[1].Key)
|
|
require.EqualValues(t, 1, buckets[1].Count)
|
|
require.EqualValues(t, 1, buckets[1].Users)
|
|
|
|
// by platform with a filter applied (only network rows): darwin=2, windows=1.
|
|
buckets, err = AggregateTelemetryEvents(TelemetryQueryFilter{ErrorCategory: "network"}, "platform", 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, buckets, 2)
|
|
require.Equal(t, "darwin", buckets[0].Key)
|
|
require.EqualValues(t, 2, buckets[0].Count)
|
|
|
|
// unsupported dimension is rejected (SQL-injection guard).
|
|
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "user_id; drop table", 0)
|
|
require.Error(t, err)
|
|
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "", 0)
|
|
require.Error(t, err)
|
|
}
|