一次页面访问,Jekit core 主动上报的请求体只有 10 个字节。
其中没有 URL、Cookie、IP 字段或完整 Referrer。
数据结构决定了能收集什么
Jekit 没有先收集原始数据,再靠使用政策限制用途。它直接从上报协议里删掉了 URL、Cookie、IP 和完整 UserAgent 等字段。
| 常见统计工具上传的内容 | Jekit 上传的内容 |
|---|---|
| 完整 URL、Referrer | 路径的 32 位哈希、来源渠道编号 |
| Cookie / 访客 ID / 设备指纹 | 无 |
| IP 地址 | 无,协议里没有这个字段 |
| UserAgent 字符串 | 浏览器、系统各一个分类编号 |
| 停留时长、点击流、滚动深度 | 无 |
当前 /greet schema 只能容纳右栏这些定长数据。要上传其他内容,必须先修改公开的协议定义和服务端实现。
一、设计依据
这套方案主要依据三条原则:
- Privacy by Design(Ann Cavoukian 提出,后被 GDPR 第 25 条吸收为“默认的数据保护”):隐私不是在产品做完之后加的一层,而是在设计阶段就定死的结构。
- 数据最小化(GDPR 第 5 条):只为明确的目的收集最少的数据。
- 少存一份原始数据,就少承担一份泄漏风险:即使业务暂时不用,进入存储系统的数据仍需要长期保护。
工程实现遵循同一个方向:尽量让不该出现的数据无法进入当前 schema。相比在文档里要求开发者“不要记录 IP”,协议约束更容易审查,也更难被无意绕过。
二、上报协议只有 10 字节
core 的协议只有 5 种标量类型和一种定长数组,没有 string。
// protocol/type.ts(节选,略去每种类型的 key 字段)
export const base = {
bool: { bytes: 1, length: 1 },
u8: { bytes: 1, length: 1 },
u16: { bytes: 2, length: 1 },
u32: { bytes: 4, length: 1 },
u64: { bytes: 8, length: 1 },
} as const;编码器只接受整数,越界直接抛错:
function assertIntegerInRange(typeKey: 'u8' | 'u16' | 'u32', value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new Error(`[encoder] ${typeKey} expects an integer number`);
}
// …按类型取最大值
if (value < 0 || value > max) {
throw new Error(`[encoder] ${typeKey} out of range: ${value}`);
}
return value;
}上传时用 DataView 写成小端二进制,不走 JSON。一次页面访问(/greet)的完整请求体,就是 schema 里的这 7 个字段:
const reqBuf = defineBuffer(dto, {
visitorStatus: ctx.getVisitorStatus(), // u8
whereWasIFrom: ctx.whereWasIFrom(), // u8
theHashOfPath: ctx.getHashOfCurrentPath(), // u32
whichBrowser: ctx.getWhichBrowser(), // u8
whichOS: ctx.getWhichOS(), // u8
ttfb: performanceMetrics.ttfb, // u8
plt: performanceMetrics.plt, // u8
});1 + 1 + 4 + 1 + 1 + 1 + 1 = 10 字节,返回体 40 字节。
当前 schema 没有 URL 字段。若以后要传 URL 或其他原始字符串,必须同时修改协议、客户端和服务端;这会成为一次可见的设计变更。
三、7 个字段怎样在浏览器内完成降维
能在浏览器里完成的分类,就不把原始材料交给服务端。
3.1 页面标识:URL 变成 4 字节
normalizePageUri() 先把路径、路由型 hash 和查询参数白名单拼成页面标识,再用 FNV-1a 压成 32 位:
export function getHashOfPagePath(path: string): number {
return fnv1a32(normalizePageUri(path));
}服务端收到的 theHashOfPath 永远是一个 u32。它知道“这是某个页面”,但不知道这个页面的地址是什么。
页面为什么这样切分,可以看 Jekit 如何区分不同的页面?。
3.2 访客状态:去重判断在浏览器里做完
UV 通常依赖稳定的访客 ID,由服务端完成去重。Jekit 没有上传这个 ID,而是让浏览器判断“今天是否看过这个站点和页面”,服务端只收到一个 4 态枚举。
export enum visitorStatusOption {
NewUser_TodayNewSite_TodayNewPage = 1, // 新用户 + 今天新站点 + 今天新页面
ExistingUser_TodayNewSite_TodayNewPage, // 老用户 + 今天新站点 + 今天新页面
ExistingUser_ExistingSite_TodayNewPage, // 老用户 + 已有站点 + 今天新页面
ExistingUser_ExistingSite_ExistingPage, // 老用户 + 已有站点 + 已有页面
}本地状态存在 localStorage,不会像 Cookie 那样随 HTTP 请求自动发出。L1 固定为 15 字节;当天访问页面超过 10 个后,还会按需创建 108 字节的 L2 扩展层:
// [1字节上次访问日期][1字节今天访问过的页面数量][今日13字节布隆过滤器] = 15字节
const keyL1 = "jekit-l1";
const keyL2 = "jekit-l2"; // [今日108字节布隆过滤器扩展]13 字节 = 104 位,配 7 个哈希,够记住“今天看过的 10 个页面”,记满 10 个时假阳性约 0.7%。
布隆过滤器只会误判“看过”,不会漏判“没看过”。所以判定逻辑是两级:L1 说没看过就一定没看过;L1 说看过,超过 10 个页面后再交给 L2 复核。
// 仅当今天访问页面数超过 10 且 L2 存在时才去碰 I/O
let bloomL2: BloomFilter | null = null;
let hasL2 = false;
if (todayVisitedPages > 10) {
const base64L2 = window.localStorage.getItem(keyL2);
// …
}一个 32 位哈希拆成两个 16 位种子,用双哈希(Kirsch-Mitzenmacher)生成 7 个位索引,省掉 7 次真实哈希:
private getBloomIndices(hash: number): number[] {
// 拆分高低 16 位作为相互独立的原始哈希种子
const hashA = hash & 0xFFFF;
const hashB = (hash >> 16) & 0xFFFF;
const indices: number[] = new Array(this.numHashes);
for (let i = 0; i < this.numHashes; i++) {
let idx = (hashA + i * hashB) % this.totalBits;
// 兜底处理 JavaScript 中负数取模的问题
if (idx < 0) idx += this.totalBits;
indices[i] = idx;
}
return indices;
}跨天会整份重写,昨天的扩展位图直接删掉:
if (lastVisit !== today) {
// 跨天后删除昨天的 L2 扩展层
window.localStorage.removeItem(keyL2);
}写入的日期是北京时间的“第几天”,只占 1 字节。跨天后,页面访问状态会被重置。
3.3 来源:只上传渠道编号
document.referrer 在本地读完就地映射成枚举,原文不出浏览器:
const referrer = document.referrer;
const lowerReferrer = referrer.toLowerCase();
if (lowerReferrer.includes('google.')) return whereWasIFromOption.Google;
if (lowerReferrer.includes('baidu.com')) return whereWasIFromOption.Baidu;
// …共 23 个取值服务端只知道“这次访问来自 Google / 来自 ChatGPT / 直接访问”,不知道来源页面的地址,更拿不到搜索词。顺带说一句,AI 导流大多不留 Referrer,所以这里会结合当前页面的 URL 参数再看一眼(utm_source 之类)。
枚举从 1 开始编号,0 留给“未知”,这样任何一个渠道都不可能被误判成“没设置”。
3.4 环境:UserAgent 只用来查表
export function getWhichBrowser(): whichBrowserOption {
const ua = navigator.userAgent;
if (ua.indexOf('Edg') !== -1) return whichBrowserOption.Edge;
if (ua.indexOf('Chrome') !== -1) return whichBrowserOption.Chrome;
// …共 8 个取值
}浏览器 8 类、系统 7 类,UA 字符串本身不会离开浏览器。
core 也没有读取 navigator.language、navigator.platform、hardwareConcurrency、deviceMemory 这类常见的设备指纹原料。
3.5 性能:把毫秒量化成 1 字节
// 单位是10ms,范围是[0,252]
// 253代表大于或者等于 2.53 秒
// 254代表无需采集(spa虚拟路由时的情况)
// 255代表采集失败
let ttfb = (navEntry.responseStart - navEntry.requestStart) / 10;
ttfb = Math.max(0, Math.min(253, Math.ceil(ttfb)));量化会丢掉毫秒级细节,降低单条性能数据的可区分性;它不等同于正式的 k-匿名保证。两个性能指标因此也只占 2 个字节。
四、服务端拿到什么,面板就只能问什么
每条上报在服务端能落地的东西只有:
- 站点(归属靠浏览器自动携带的 Origin,客户端不额外上传站点标识)
- 页面哈希(u32)
- 日期
- 访客状态、来源渠道、浏览器、系统(4 个枚举编号)
- TTFB、PLT(2 个字节)
UV 不是一组去重后的访客 ID,而是把 visitorStatus 中表示“今天首次访问该页面”的状态累加起来。按当前业务数据结构,服务端没有用于回查单个访客的标识。
查询接口同样只接受整数:要历史数据,得传 range(日/月/年)、metric(指标编号)、dimensionValue(渠道/浏览器/系统编号)这些枚举,再加一个页面哈希。
统计面板无法查看某次访问明细,因为协议没有采集生成这类明细所需的数据。
传输层还做了几项限制:
- 请求带
referrerPolicy: "no-referrer",连 Referer 头都不发 - SPA 路由切换 50ms 去抖 + 序号丢弃竞态,一次导航只产生一条上报
- 爬虫环境、localhost 直接不上报
const res = await fetch(domain + props.target, {
method: 'POST',
headers: props.headers,
body: props.buffer,
referrerPolicy: "no-referrer",
});五、代价和边界
这套设计也有明确的代价:
- 无 Cookie 的代价:换设备、清缓存、开无痕都会各算一个新访客,UV 偏大。想要精确的跨设备去重,就必须引入稳定 ID,那是我们不打算做的事。
- 布隆过滤器会误判:记满 10 个页面时约 0.7% 的访问会被当成“已看过”,UV 略偏小;一天里访问几百个页面的重度用户会让位图逐渐饱和,少计更明显。两股偏差会抵消一部分,但都不会是 0。
- 时间窗口只有当天:不做 30 天留存、不做跨会话归因、不做转化漏斗。
- 32 位哈希不是加密:理论上可以用“猜一个 URL → 算一次哈希 → 比对”的方式,验证某个地址是否被统计过。它换来的是服务端手里没有原文,也没有可以还原的对照表。
- IP 在传输层一定会被看见:否则服务器无法回包。Jekit core 的上报协议不包含 IP 字段;CDN、反向代理和 Web 服务器是否保留 IP,还取决于实际部署时的日志配置,不能只靠这段客户端代码保证。
- UserAgent 请求头由浏览器强制携带:我们能做的是不把它存下来。
Jekit 选择少收数据,也接受由此带来的统计误差和功能限制。
六、自己验证一下
这些结论可以直接用 DevTools 核对:
- Network → 找发往
api.jekit.cn的/greet请求 → 请求体是 10 个字节的二进制;在 Payload 和请求头中分别核对 URL、Referer、Cookie - Application → Local Storage → 查看
jekit-l1和按需出现的jekit-l2;L1 解码后为 15 字节,页面访问状态跨天重置 - Application → Cookies → 空的,整个 core 里没有一处
document.cookie
源码都在 jekit-sdk 里,packages/core/src/utils/ctx.ts 是这套机制的主场;想直接看数据,去统计面板。
