您现在处于Jekit 每次访问只上传 10 字节:无 Cookie 统计是怎么做的
jekit免费 · 公开 · 高效
返回博客列表

Jekit 每次访问只上传 10 字节:无 Cookie 统计是怎么做的

Jekit 每次页面访问只上传 7 个整数,共 10 字节。本文说明页面哈希、本地访客去重、来源分类和性能量化的实现,以及这套隐私设计牺牲了什么。

浏览量:Err·查看修改记录

一次页面访问,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 是这套机制的主场;想直接看数据,去统计面板。