feat(server): ingest v2事件级归属+session_resume+stay区间并集重算+响应明细契约
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,26 @@
|
||||
① event 绝不重复入库:uq_site_event + bulk_create(ignore_conflicts=True) + 入库前 existed 预查
|
||||
+ 同批 event_id 去重(seen_eids);
|
||||
② 重放命中的事件不参与会话聚合(new_events 只含未入库事件);
|
||||
③ visit_count/active_days/event_count/duration_ms/stay_ms 全走 F 表达式,
|
||||
filter().update() 避免 read-modify-write 全量 save() 用旧值覆盖并发赢家;
|
||||
③ visit_count/active_days/event_count/duration_ms 走 F 表达式,filter().update() 避免
|
||||
read-modify-write 全量 save() 用旧值覆盖并发赢家;stay_ms 是唯一例外——schema v2 起
|
||||
改为落库后按 Event 表重算(v1 active_ms 加法 + v2 page_id 区间并集,见
|
||||
_recompute_session_stay;15s 周期切分的相邻区间须并集拼回,增量累加会重复计),
|
||||
写入仍是单行 filter().update();
|
||||
④ 会话 ended_at 用 Greatest 只前进不回退,乱序批次不改早结束时间;
|
||||
⑤ 同批切分出的会话段按"原始 sid"缓存复用,后续事件不再切分。
|
||||
|
||||
schema v2(阶段A):批级 schema_version==2 时事件自带归属四元组(site/visitor/session/
|
||||
page_id),会话按事件级 session_id 解析(v1 信封不再是唯一依据);v2 不做 30min 服务端
|
||||
兜底切分(会话切换由 SDK 显式 session_resume 驱动,避免双切);session_resume 创建的
|
||||
新会话经 resume_of 链接前会话。v1 路径完全保留。
|
||||
|
||||
已接受的残余风险(M1 不修):existed 预查与 bulk_create 之间存在毫秒级 TOCTOU 窗口——
|
||||
并发请求在预查后、写入前提交同一 event_id 时,本请求的 ignore_conflicts 静默吞掉冲突,
|
||||
但该事件已被计入 new_events 参与聚合,聚合可能双计。窗口极窄且仅影响并发重放场景。
|
||||
(v2 附注:stay_ms 重算对"并发同会话双批"同样存在同类窗口——后提交者的 SELECT 可能
|
||||
看不到前者未提交事件而少算,与 TOCTOU 同级、只影响 stay_ms 单字段。)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone as dt_tz
|
||||
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import F, Q
|
||||
@@ -31,6 +41,35 @@ _CLASS_FIELDS = ("channel", "platform", "variant", "search_term",
|
||||
"evidence_kind", "resolution", "detail")
|
||||
|
||||
|
||||
def _occ_dt(v):
|
||||
"""v2 事件 occurred_at 允许 ISO 字符串(SDK 线格式;collect 视图已预转 datetime,
|
||||
直连调用方可能传原文)。解析失败抛 ValueError/TypeError——调用方放行原值,
|
||||
由 validate_event 统一拒 invalid_occurred_at。naive 视为 UTC(与 collect._iso 同口径)。"""
|
||||
if isinstance(v, str):
|
||||
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc)
|
||||
return v
|
||||
|
||||
|
||||
def _ms(v):
|
||||
"""v2 区间字段宽松 int 化:bool/非数值 → None(脏数据防御——recompute 会过滤 None 区间,
|
||||
该事件不参与并集但不拒批)。"""
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||
return None
|
||||
return int(v)
|
||||
|
||||
|
||||
def _time_quality(now, occ) -> str:
|
||||
"""v2 时间质量分类:received−occurred 差 d 秒——d>+120s 迟到 "late"、d<-120s 时钟倒偏
|
||||
"skew_backwards"、否则 "ok"。v1 事件留空(late 布尔已有,不动)。"""
|
||||
d = (now - occ).total_seconds()
|
||||
if d > C.CLOCK_SKEW_LATE_SEC:
|
||||
return "late"
|
||||
if d < -C.CLOCK_SKEW_LATE_SEC:
|
||||
return "skew_backwards"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _clean_utm(d):
|
||||
"""utm 键白名单(utm_* 前缀)+ 截断——任意字符串不得经 utm 入库渲染。"""
|
||||
return {k[:50]: str(v)[:100] for k, v in (d or {}).items()
|
||||
@@ -134,17 +173,26 @@ def _touch_visitor(site, vid, ctx, ip):
|
||||
return v, created # created 供首触来源分类(ingest_batch 消费)
|
||||
|
||||
|
||||
def _get_session(site, visitor, sid, ctx, occurred_at, ua="", referrer_url=""):
|
||||
def _get_session(site, visitor, sid, ctx, occurred_at, ua="", referrer_url="",
|
||||
skip_idle_split=False):
|
||||
"""会话权威在 SDK;服务端兜底:同 session_id 族(s1、s1#2…)距最后事件>30min 切分后缀段。
|
||||
|
||||
空闲比较必须取整个会话族的最大 ended_at:若只查根段(session_id=sid 精确匹配),
|
||||
切分一次后根段 ended_at 陈旧,后续小间隔批次会反复误判超时、每批新开段(visit_count 灌倍)。
|
||||
|
||||
skip_idle_split=True(schema v2):SDK 显式管理会话(session_resume 驱动切换),服务端
|
||||
兜底不介入(避免双切);"复用现有段"能力保留——直接按 sid 精确查/建(uq_site_session
|
||||
保证至多一行),不复用 sid#N 族。
|
||||
"""
|
||||
idle = timedelta(milliseconds=C.SESSION_IDLE_MS)
|
||||
fam = list(VisitSession.objects.filter(
|
||||
Q(session_id=sid) | Q(session_id__startswith=sid + "#"), site=site))
|
||||
last = max(fam, key=lambda s: s.ended_at) if fam else None
|
||||
if last and occurred_at - last.ended_at > idle:
|
||||
if skip_idle_split:
|
||||
fam = []
|
||||
last = VisitSession.objects.filter(site=site, session_id=sid).first()
|
||||
else:
|
||||
fam = list(VisitSession.objects.filter(
|
||||
Q(session_id=sid) | Q(session_id__startswith=sid + "#"), site=site))
|
||||
last = max(fam, key=lambda s: s.ended_at) if fam else None
|
||||
if last and not skip_idle_split and occurred_at - last.ended_at > idle:
|
||||
suffix = 2 + sum(1 for s in fam if s.session_id.startswith(sid + "#"))
|
||||
sid, sess = f"{sid}#{suffix}", None
|
||||
else:
|
||||
@@ -173,6 +221,39 @@ def _get_session(site, visitor, sid, ctx, occurred_at, ua="", referrer_url=""):
|
||||
return sess
|
||||
|
||||
|
||||
def _recompute_session_stay(sess):
|
||||
"""stay_ms 唯一聚合路径(invariant ③ 唯一例外):落库后从 Event 表重算回写。
|
||||
|
||||
v1/v2 混合的单一解释:v1 事件(page_id 空)沿用 active_ms 加法;v2 事件按 page_id
|
||||
分组做 (active_from_ms, active_to_ms) 区间并集后求和;两者相加。重算式而非增量——
|
||||
SDK 每 15s 切一刀的相邻区间须并集拼回(增量累加会重复计);uq_site_event 存储层
|
||||
去重保证重算不重复。缺区间/倒置区间的 v2 脏事件整条跳过,不参与并集也不拒批。
|
||||
v1-only 会话重算值与旧增量语义恒等(v1_total 即历史 active_ms 全和)。
|
||||
"""
|
||||
v1_total, by_page = 0, {}
|
||||
for page_id, af, at, props in Event.objects.filter(
|
||||
session=sess, event_name="page_stay").values_list(
|
||||
"page_id", "active_from_ms", "active_to_ms", "properties"):
|
||||
if page_id:
|
||||
# 倒置区间(to<from)一并跳过:参与求和会得负值,炸 PositiveBigInteger 列
|
||||
if af is not None and at is not None and at >= af:
|
||||
by_page.setdefault(page_id, []).append((af, at))
|
||||
else:
|
||||
v1_total += int((props or {}).get("active_ms", 0) or 0)
|
||||
union_total = 0
|
||||
for ivs in by_page.values():
|
||||
ivs.sort()
|
||||
cur_s, cur_e = ivs[0]
|
||||
for s, e in ivs[1:]:
|
||||
if s <= cur_e: # 相邻/重叠区间拼接(15s 周期切一刀的场景)
|
||||
cur_e = max(cur_e, e)
|
||||
else:
|
||||
union_total += cur_e - cur_s
|
||||
cur_s, cur_e = s, e
|
||||
union_total += cur_e - cur_s
|
||||
VisitSession.objects.filter(pk=sess.pk).update(stay_ms=v1_total + union_total)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> dict:
|
||||
now = timezone.now()
|
||||
@@ -198,9 +279,16 @@ def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> di
|
||||
referrer_url=_referrer_url(ctx.get("referrer_domain"), pv_referrer),
|
||||
utm=_clean_utm(ctx.get("utm")),
|
||||
landing_path=str(ctx.get("path") or ""), ua=ua or "")
|
||||
v2 = payload.get("schema_version") == 2 # 批级信(缺省 = v1 旧 SDK,路径完全不变)
|
||||
ready, session_cache, seen_eids = [], {}, set()
|
||||
for i, ev in enumerate(events):
|
||||
reason = validate_event(ev)
|
||||
if v2 and isinstance(ev, dict) and isinstance(ev.get("occurred_at"), str):
|
||||
try: # v2 事件 occurred_at 允许 ISO 字符串(SDK 线格式);解析失败放行原值,
|
||||
ev["occurred_at"] = _occ_dt(ev["occurred_at"]) # validate_event 统一拒 invalid_occurred_at
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
reason = validate_event(ev, v2=v2, batch_site_id=site.site_id,
|
||||
batch_visitor_id=payload["visitor_id"])
|
||||
if reason:
|
||||
result["rejected"].append({"index": i, "event_id": ev.get("event_id", ""),
|
||||
"reason": reason})
|
||||
@@ -210,13 +298,33 @@ def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> di
|
||||
continue
|
||||
seen_eids.add(ev["event_id"])
|
||||
occ = ev["occurred_at"]
|
||||
sid = payload["session_id"]
|
||||
sid = ev["session_id"] if v2 else payload["session_id"] # v2:归属按事件级字段解析
|
||||
sess = session_cache.get(sid)
|
||||
if not sess:
|
||||
sess = _get_session(site, visitor, sid, ctx, occ, ua=ua or "",
|
||||
referrer_url=pv_referrer)
|
||||
referrer_url=pv_referrer, skip_idle_split=v2)
|
||||
session_cache[sid] = sess # 按原始 sid 存:切分后 sess.session_id 是 "s1#2",
|
||||
# 若按它存,同批后续事件按 "s1" 查会 miss → 每条再切新会话
|
||||
if v2 and ev["event_name"] == "session_resume" and sess.resume_of_id is None:
|
||||
# 续接链:resume_of 指向 prev_session_id 对应会话;prev 无效/不存在软处理(None,不拒)。
|
||||
# resume_of__isnull=True 条件幂等——重放/同批多条 resume 不覆写已设的链。
|
||||
prev = (ev.get("properties") or {}).get("prev_session_id")
|
||||
prev_sess = (VisitSession.objects.filter(site=site, session_id=prev).first()
|
||||
if isinstance(prev, str) and prev else None)
|
||||
if prev_sess and prev_sess.pk != sess.pk:
|
||||
VisitSession.objects.filter(pk=sess.pk, resume_of__isnull=True).update(
|
||||
resume_of=prev_sess)
|
||||
sess.resume_of = prev_sess # 刷新内存态:同批同 sid 后续 resume 不再重查重设
|
||||
extra = {}
|
||||
if v2:
|
||||
props = ev.get("properties") or {}
|
||||
extra = {"page_id": str(ev.get("page_id") or "")[:64],
|
||||
"active_from_ms": _ms(props.get("active_from")),
|
||||
"active_to_ms": _ms(props.get("active_to")),
|
||||
"flush_reason": str(props.get("flush_reason") or "")[:16],
|
||||
"time_quality": _time_quality(now, occ),
|
||||
"sdk_version": str(payload.get("sdk_version") or "")[:16],
|
||||
"schema_version": 2}
|
||||
ready.append(Event(
|
||||
site=site, visitor=visitor, session=sess,
|
||||
event_id=ev["event_id"], event_name=ev["event_name"],
|
||||
@@ -225,7 +333,7 @@ def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> di
|
||||
properties=ev.get("properties") or {},
|
||||
ip=ip, late=abs((now - occ).total_seconds()) > C.CLOCK_SKEW_LATE_SEC,
|
||||
country=_geo_field(geo, "country")[:64], province=_geo_field(geo, "province")[:64],
|
||||
city=_geo_field(geo, "city")[:64], is_bot=is_bot))
|
||||
city=_geo_field(geo, "city")[:64], is_bot=is_bot, **extra))
|
||||
if ready:
|
||||
existed = set(Event.objects.filter(
|
||||
site=site, event_id__in=[e.event_id for e in ready]
|
||||
@@ -238,11 +346,9 @@ def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> di
|
||||
se = [e for e in new_events if e.session.pk == sess.pk]
|
||||
new_end = max(x.occurred_at for x in se)
|
||||
span_ms = int((new_end - min(x.occurred_at for x in se)).total_seconds() * 1000)
|
||||
stay_add = sum(int(e.properties.get("active_ms", 0) or 0)
|
||||
for e in se if e.event_name == "page_stay")
|
||||
VisitSession.objects.filter(pk=sess.pk).update(
|
||||
ended_at=Greatest(F("ended_at"), new_end), # 只前进不回退,防乱序批次改早结束时间
|
||||
event_count=F("event_count") + len(se),
|
||||
duration_ms=F("duration_ms") + span_ms,
|
||||
stay_ms=F("stay_ms") + stay_add) # 有效停留单独累计,供访客"总停留"取数
|
||||
duration_ms=F("duration_ms") + span_ms)
|
||||
_recompute_session_stay(sess) # stay_ms 走重算(v1 加法 + v2 区间并集),不再 F 增量
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user