按发生时间倒序展示客户端埋点
调整事件查询排序及分页游标,保留入库时间快照隔离。 同步后台说明、定向测试和技术文档。
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user