按发生时间倒序展示客户端埋点

调整事件查询排序及分页游标,保留入库时间快照隔离。
同步后台说明、定向测试和技术文档。
This commit is contained in:
2026-09-22 12:48:49 +00:00
parent fe19a2c4db
commit e07b12daf9
6 changed files with 28 additions and 24 deletions
@@ -166,7 +166,7 @@ export function AdminAgcTrackingPage({
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
<p></p>
</div>
<button
type="button"
@@ -1,5 +1,10 @@
# 决策记录
## 2026-09-22 客户端埋点后台按发生时间排序
- 按技术负责人要求,列表改为 `(event_time, event_id)` 倒序,历史补传按发生时间归位。入库时间仍用于固定分页快照,翻页期间新入库的事件在刷新后显示。
- 分页游标改存发生时间;旧游标刷新后重新取得,不迁移持久表、不新增索引、不改变采集与上传行为。
## 2026-09-22 埋点分支同步发布入口与工作台更新
- 合并 master `079466b29`,同时保留项目离开埋点与工作台运行通知、后台埋点查询与游戏发布审核入口;不扩大采集范围。
@@ -1,6 +1,6 @@
# 客户端本地埋点与主站入库契约
Version: 0.27
Version: 0.28
Status: 本地采集及上传、入库、成功清理、后台查询已实现并完成本地隔离环境验收;未部署生产
Date: 2026-09-21
需求来源:[Game Agent 埋点设计原始方案](./【需求来源】GameAgent埋点设计原始方案-2026-09-05.md);原始方案与后续已确认决策有差异时,以本文为准。
@@ -675,8 +675,8 @@ batch_id 是传输关联标识,event_id 是持久幂等依据。一张表不
- UI 首版展示发生时间范围、用户、项目和事件类型;其他关联筛选可通过详情入口携带,均使用精确匹配,不引入全文搜索。
- 时间范围按 event_time,起点包含、终点不包含;时间格式为带时区 RFC3339。默认不设时间过滤,默认每页 50 条,最大 200 条。
- 返回 payload`entries``nextCursor`。条目包括表内字段的 camelCase 映射,`properties` 返回解析后的 JSON 对象;时间转为 UTC 字符串,Option 转 null,不向 UI 暴露 SATS 原始值。
- 列表按首次入库时间 received_at 倒序,同时间按 event_id 稳定排序;明确列标题为“入库时间”和“发生时间”,不混称最新发生的事件
- cursor 固定查询快照上界、最后一行的 (received_at, event_id) 和筛选摘要;切换筛选重置游标,非法游标返回 400。新增数据点击刷新后出现,分页不依赖会随插入漂移的 offset。
- 列表按发生时间 event_time 倒序,同时间按 event_id 倒序稳定排序;明确列标题为“入库时间”和“发生时间”,补传的历史事件按实际发生时间归位
- cursor 固定入库时间快照上界、最后一行的 (event_time, event_id) 和筛选摘要;切换筛选重置游标,非法游标返回 400。新增数据点击刷新后出现,分页不依赖会随插入漂移的 offset。旧排序游标失效,刷新列表重新查询;不改变存储表及索引。
- 由数据库查询路径按索引及条件确定排序候选与页边界;若当前 SQL 不支持排序,通过受限 typed procedure 完成。不能任取 LIMIT N 行后排序并宣称为全表最新;实现计划须在现役 2.8.3 上验证此点,不照搬旧页面的截断查询方式。
列表列:入库时间、发生时间、用户、事件中文名、项目、来源、结果、客户端版本。空值显示“—”。详情展示全部既有字段与格式化 properties,包括会话/run/批次 ID;不新增编辑或删除按钮。空结果、加载失败、无权限沿用后台现有反馈,移动端表格支持横向滚动。
@@ -709,7 +709,7 @@ batch_id 是传输关联标识,event_id 是持久幂等依据。一张表不
运行时 smoke 必须包含真实 SpacetimeDB 提交及后台页面;mock 请求成功不算完成入库验收。不需要为本轮重新调用付费模型或扩大 Agent 创作测试。
本版实施口径为:只接收已登录用户、一张事件表、原子整批提交、首轮等待 15 分钟、30 秒请求超时、20 批/2 分钟轮次预算、失败下周期重试、后台按入库时间分页、服务端暂不自动过期。已确认的采集范围、上传周期、静默非阻塞和成功删除原则不变。
本版实施口径为:只接收已登录用户、一张事件表、原子整批提交、首轮等待 15 分钟、30 秒请求超时、20 批/2 分钟轮次预算、失败下周期重试、后台按发生时间倒序分页、服务端暂不自动过期。已确认的采集范围、上传周期、静默非阻塞和成功删除原则不变。
### 13.11 实现与验证记录(2026-09-21
+8 -5
View File
@@ -160,6 +160,7 @@ async function verify(url, serviceToken, outsiderToken) {
),
);
const first = batch([event(), event(), event()]);
first.events[0].event_time = '2026-09-21T12:01:00.123Z';
assert.deepEqual(ok(await upload(first)), {
acknowledged_batch_ids: [first.batch_id],
event_count: 3,
@@ -207,6 +208,7 @@ async function verify(url, serviceToken, outsiderToken) {
const page1 = await list({ limit: 2 });
assert(page1.nextCursor);
const late = batch([event()]);
late.events[0].event_time = '2026-09-21T11:00:00.123Z';
ok(await upload(late));
const seen = [...page1.entries];
let cursor = page1.nextCursor;
@@ -225,17 +227,18 @@ async function verify(url, serviceToken, outsiderToken) {
const previous = seen[index - 1];
const current = seen[index];
assert(
previous.receivedAt > current.receivedAt ||
(previous.receivedAt === current.receivedAt &&
previous.eventTime > current.eventTime ||
(previous.eventTime === current.eventTime &&
previous.eventId > current.eventId),
'Stable receipt/event ID descending order',
'Stable event time/event ID descending order',
);
}
const refreshed = await list();
assert.equal(refreshed.entries.length, 5);
assert.equal(refreshed.entries[0].eventId, late.events[0].event_id);
assert.equal(refreshed.entries[0].eventId, first.events[0].event_id);
assert.equal(refreshed.entries.at(-1).eventId, late.events[0].event_id);
assert.equal(refreshed.entries[0].projectId, null);
assert.equal(refreshed.entries[0].eventTime, '2026-09-21T12:00:00.123Z');
assert.equal(refreshed.entries[0].eventTime, '2026-09-21T12:01:00.123Z');
for (const [name, input] of [
['upload_agc_analytics_batch', first],
['list_agc_tracking_events', {}],
@@ -214,7 +214,7 @@ pub fn validate_agc_analytics_batch(batch: &AgcAnalyticsBatch) -> Result<(), &'s
#[serde(deny_unknown_fields)]
pub struct AgcTrackingCursor {
pub snapshot: i64,
pub received_at: i64,
pub event_time: i64,
pub event_id: String,
pub filter_key: String,
}
@@ -265,10 +265,7 @@ pub fn validate_agc_tracking_query(
}
let cursor: AgcTrackingCursor =
serde_json::from_str(raw).map_err(|_| "invalid_agc_cursor")?;
if cursor.filter_key != agc_tracking_filter_key(query)
|| !uuid(&cursor.event_id)
|| cursor.received_at > cursor.snapshot
{
if cursor.filter_key != agc_tracking_filter_key(query) || !uuid(&cursor.event_id) {
return Err("invalid_agc_cursor");
}
Ok(Some(cursor))
@@ -408,7 +405,8 @@ mod tests {
let mut query = shared_contracts::admin::AdminAgcTrackingEventListQuery::default();
let cursor = AgcTrackingCursor {
snapshot: 100,
received_at: 90,
// 客户端时钟可能领先;快照限制入库时间,不能限制发生时间。
event_time: 110,
event_id: ID.into(),
filter_key: agc_tracking_filter_key(&query),
};
@@ -198,21 +198,19 @@ pub fn list_agc_tracking_events(
.map(|v| v.snapshot)
.unwrap_or(tx.timestamp.to_micros_since_unix_epoch());
let limit = query.limit.unwrap_or(50).clamp(1, 200) as usize;
let upper = Timestamp::from_micros_since_unix_epoch(
cursor.as_ref().map(|v| v.received_at).unwrap_or(snapshot),
);
let upper = Timestamp::from_micros_since_unix_epoch(snapshot);
let mut selected = BTreeMap::new();
// 在数据库事务中扫描完整索引候选,再保留最大的 limit+1 个键。
// 不依赖 SDK 迭代器顺序,不将任意 LIMIT 截断误作最新一页,内存只保留一页。
let mut consider = |row: AgcTrackingEvent| {
let key = (
row.received_at.to_micros_since_unix_epoch(),
row.event_time.to_micros_since_unix_epoch(),
row.event_id.clone(),
);
if key.0 > snapshot
if row.received_at.to_micros_since_unix_epoch() > snapshot
|| cursor
.as_ref()
.is_some_and(|v| key >= (v.received_at, v.event_id.clone()))
.is_some_and(|v| key >= (v.event_time, v.event_id.clone()))
|| !matches_query(&row, &query, start, end)
{
return;
@@ -253,11 +251,11 @@ pub fn list_agc_tracking_events(
let has_more = selected.len() > limit;
let mut rows: Vec<_> = selected.into_iter().rev().take(limit).collect();
let next_cursor = if has_more {
let ((received_at, event_id), _) = rows.last().expect("nonempty page");
let ((event_time, event_id), _) = rows.last().expect("nonempty page");
Some(
serde_json::to_string(&AgcTrackingCursor {
snapshot,
received_at: *received_at,
event_time: *event_time,
event_id: event_id.clone(),
filter_key: agc_tracking_filter_key(&query),
})