PostHog 推送订阅注册机制解析:/api/push_subscriptions/、push.appIds远程配置与移动 SDK 端到端协议
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
PostHog 的推送通知采用"按项目显式开启(opt-in)、移动 SDK 无法提前感知"的设计,因此每个开启了 push capture 的 App 在每次启动时都会向服务端注册设备 token。本文基于 docs/internal/push-subscription-registration.md 展开,完整剖析注册请求的流转路径、服务端对未配置app_id的特殊处理、push.appIds远程配置键的语义,以及移动 SDK 必须遵守的三条注册规则与磁盘缓存策略。读完本文,你将掌握这套注册协议的全部细节,能够在自研 SDK 或自托管部署中正确实现、排查推送注册链路。
整体流程:一次推送注册是如何发生的
端到端数据流
整个推送注册协议由一条链路串起:
- 开启 push capture 的移动 SDK 在 App 启动时,向
/api/push_subscriptions/发起POST,提交设备 token; - 服务端用请求中的
api_key(项目 token)解析出 team; - 服务端将
app_id解析为团队配置的 Firebase 或 APNs 集成; - 若解析成功,token 被加密后以 person property
$device_push_subscription_<app_id>的形式写入用户画像(person profile); - 若解析失败(团队根本没有为该
app_id配置集成),服务端返回{"stored": false, "push_enabled": false}并不存储token。
路由注册位于 posthog/urls.py:opt_slash_path("api/push_subscriptions", push_subscriptions),视图实现位于 products/messaging/backend/api/push_subscriptions.py(@csrf_exempt的push_subscriptions视图,同时处理POST与DELETE,并显式支持OPTIONS预检)。
存储的底层实现
注册成功的落库并不是直接写数据库,而是通过capture_internal生成一条$set事件进入摄取管道(push_subscriptions.py):
if request.method == "POST": properties = {"$set": {property_key: _encrypted_fields.encrypt(device_token)}} else: properties = {"$unset": [property_key]}property_key为$device_push_subscription_<app_id>,其中<app_id>是请求提交的 Firebaseproject_id或 APNsbundle_id;- device token 在存储前经过
EncryptedFieldMixin.encrypt()加密(Fernet token),原始 token 不会以明文进入事件流——这一点由 test_token_is_encrypted 测试锁定:断言加密值不等于原始 token 且为非空字符串; DELETE(登出)执行$unset,且$unset一个不存在的属性是 no-op,因此注销天然幂等;同时注销不校验客户端上次持有的 token 是否与存储值一致,只要app_id相同就清除该订阅。
请求/响应契约与字段说明
POST 请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
api_key | string | 是 | 公开项目 token,用于解析 team;缺失返回401 |
distinct_id | string | 是 | 当前用户的 distinct id,token 将绑定到该用户 |
device_token | string | 是 | Firebase/APNs 设备 token,加密后存储 |
platform | string | 是 | 必须是android或ios(VALID_PLATFORMS) |
app_id | string | 是 | Firebaseproject_id或 APNsbundle_id |
identity_token | string | 否 | 身份校验开启(optional/required)时所需的短期签名 token |
请求体上限为 16 KiB(MAX_BODY_BYTES = 16 * 1024),且在解压之前检查长度,防止压缩炸弹在load_data_from_request解压时膨胀为内存耗尽攻击(见 test_oversized_body_is_rejected_before_parsing)。SDK 可以发送 gzip 压缩的 JSON body,content-encoding: gzip会被正确解压(test_gzip_compressed_body)。
响应语义
- 注册成功:
200,body 为{"distinct_id": ..., "platform": ...},token 已作为 person property 存储; - 未配置集成(POST):
200,body 为{"stored": false, "push_enabled": false},不存储(详见下文); - 注销成功:
200,body 为{"distinct_id": ..., "platform": ...},属性被$unset; - 错误响应:统一的异常体格式,携带
code字段。
错误码一览(源码_rejection_response路径)
code | HTTP 状态 | 触发条件 |
|---|---|---|
method_not_allowed | 405 | 非POST/DELETE/OPTIONS方法 |
request_too_large | 413 | body 超过 16 KiB |
invalid_json | 400 | body 不是合法的 JSON 对象 |
missing_api_key | 401 | 未提供项目 token |
invalid_api_key | 401 | token 解析不到任何 team |
missing_fields | 400 | distinct_id/device_token/platform/app_id缺失、为空或类型错误 |
invalid_platform | 400 | platform不是android/ios |
identity_verification_failed | 401 | 集成要求required校验但 token 无效或缺失 |
capture_failed | 500 | capture_internal存储失败 |
值得注意的一个细节:missing_fields的日志会区分"字段从未发送(absent)/ 发送为空(empty)/ 类型无效(invalid)",用于区分 SDK 契约漂移与客户端桥接层传错值——这是从 push_subscriptions.py 的field_detail逻辑中可以读出的设计意图。
为什么未配置的app_id返回 200 而不是 4xx
这是整个端点设计中最反直觉、也最关键的一处。当一个团队没有配置任何推送集成时,服务端对POST返回200 {"stored": false, "push_enabled": false},且不会存储 token;但DELETE仍然执行$unset——因为登出必须清除"集成尚存在时"存储过的订阅(push_subscriptions.py 的注释与逻辑同时印证了这两点)。
返回 200 是刻意为之,理由有两条(原文档原话即源码注释的核心):
- 语义正确:请求本身没有任何无效之处——token、字段、方法全部合法,只是该团队此刻没有对应的集成。这是"账户状态"而非"请求错误";
- 成本正确:推送是每个项目可选(opt-in)的,而 SDK 无法感知项目是否开启,因此绝大多数设备的注册请求在多数团队眼里都是"无集成可注册"。若返回 4xx,这些请求会占满所有监控非 2xx 比率的告警系统,把这个端点变成一个永不停歇的"错误洪流"(error firehose)。
服务端把这种"确认收到但不存储"称为 discard(丢弃),并有专门的埋点与日志计数(见"可观测性"小节)。
服务端的快速路径:_configurable_app_ids缓存
由于"该团队从未配置过这个app_id"是常态,每次请求都走一次 JSONB 查询(_find_integrations的config__project_id/config__bundle_id过滤)太昂贵。因此视图先查一个按 team 维度缓存 60 秒的_configurable_app_ids(push_subscriptions.py):
- 命中且
app_id不在列表 → 直接走 discard 路径,跳过 JSONB 查询; - 缓存不可用(返回
None)→ 回退到真实查询,宁可多查也不误弃。原因见 test_configured_app_registers_when_the_cache_is_unavailable:若缓存故障时"fail closed",一次缓存抖动就会造成全量设备静默丢失注册,且设备因收到 200 会把 token 标记为已投递、永不重试——这比缓存失配严重得多。
缓存键只用team_id而不用请求中的app_id,是因为app_id由请求方控制,若以其为键,一个公开项目 token 就能铸造无限条缓存条目。60 秒的过期窗口最多让新配置的集成延迟一分钟生效,而设备下次启动本就会重发。
push.appIds远程配置键
语义与形态
远程配置(remote config,即/config端点返回的 SDK 配置)携带该团队可以接受注册的app_id列表:
{ "push": { "appIds": ["my-firebase-project", "com.example.app"] } }- Firebase 集成贡献其
project_id,APNs 集成贡献其bundle_id; - 该键始终存在,包括作为空列表
{"appIds": []}; - 列表中元素去重并排序(
sorted(set(...))),保证输出稳定可缓存。
absent 与 empty 是两回事
这是本协议最容易踩坑的语义陷阱:
- absent(键不存在):说明服务端早于该键的引入,SDK不能下任何结论,必须照常尝试注册;
- empty(空列表):说明服务端明确声明"该团队没有配置任何推送"。
一个把 absent 当作 empty 处理的 SDK,会停止向所有旧版自托管部署注册——而这些部署的用户将永远收不到推送。
源码实现:build_push_config
列表的构造实现在 products/messaging/backend/remote_config.py:
PUSH_APP_ID_CONFIG_KEYS = {"firebase": "project_id", "apns": "bundle_id"} def build_push_config(team: Team) -> dict: integrations = Integration.objects.filter(team=team, kind__in=list(PUSH_APP_ID_CONFIG_KEYS)).only("kind", "config") app_ids = sorted( { app_id for integration in integrations if isinstance(app_id := integration.config.get(PUSH_APP_ID_CONFIG_KEYS[integration.kind]), str) and app_id } ) return {"appIds": app_ids}关键防御逻辑:Integration.config是JSONField,理论上可以存放任意 JSON 值。若某个集成存了非字符串的标识符(如数字、数组),放任其进入appIds会在排序或集合操作中抛异常,进而让build_config整体失败、整个团队的远程配置全部过期。因此这里用isinstance(..., str) and app_id把非空字符串之外的值全部跳过。test_remote_config.py 用参数化用例锁定了这组行为:非字符串标识符被跳过、其他集成类型(如slack)被忽略、缺失 id 键的推送集成被忽略、重复值去重排序。
配置组装与自动重建
config["push"] = build_push_config(team)在 posthog/models/remote_config.py 的build_config中与其他模块(errorTracking、sessionRecording、logs等)一起组装进远程配置负载。
当一个 Firebase/APNs 集成被创建或删除时,push_integration_changed信号接收器(posthog/models/remote_config.py)会在事务提交后(transaction.on_commit)重建该团队的远程配置,无需等待一次无关的写入。该接收器有两个精细的过滤:
- 只处理
kind在PUSH_APP_ID_CONFIG_KEYS中的集成——OAuth token 刷新等无关集成变更不会触发全量重建; - 只处理
config字段实际变化的保存(检查update_fields)——因为 APNs 集成创建过程本身会多次 save(错误信息、created_by等),只有config才影响appIds载荷。
SDK 必须实现的三条规则
原文档给出了移动 SDK 端必须遵守的完整注册规则,缺一不可:
push.appIds键缺失(absent)—— 按旧行为照常注册。这保证对旧版服务端的兼容;app_id不在appIds中—— 完全跳过请求。既然服务端会丢弃它,就没有任何理由发送;app_id出现在appIds中、而此前它不在—— 清除本地"该 token 已投递"的记录,然后注册。
规则 3 是最容易被漏掉的一条。它的存在理由:一个此前未配置推送的项目后来开启了推送,但设备曾在未配置期间注册过(并收到了 200,本地记下了"已投递")。如果不清除这个本地标记,这些设备会继续跳过注册——而服务端从未保存过它们的 token——结果就是"项目刚打开推送,却永远触达不到已有的设备"。测试 test_register_without_integration_returns_200_and_discards 展示了"未配置时注册被丢弃"这一前提,正是规则 3 要修复的后续状态。
列表必须落盘缓存
注册发生在启动时,而远程配置是异步解析的。冷启动时,SDK 通常先到达注册点、/config还没返回。只查阅"本次新拉取的列表"的 SDK,依然会发出本该跳过的请求——拦截效果要等到第二次启动才生效,而不是第一次。
因此正确的做法是:push 片段到达时立即持久化,下次启动时、在第一次注册尝试之前预加载。
原文档给出了可参考的既有模式:Android 端errorTracking就是如此——processErrorTrackingConfig写入config.cachePreferences,preloadErrorTrackingConfig在启动时恢复;iOS 端镜像同样的做法。push 片段应照抄该模式。
这里还要澄清一个与规则 1 的交互:
- 冷启动且无任何缓存≠ absent 键。无缓存意味着"SDK 从未听到过这个服务器的声音",此时必须注册——这正是规则 1 的场景;
- 只有缓存中的空列表才意味着"跳过"。
换句话说,判断依据永远是"服务端到底说了什么",而不是"客户端本地有没有数据"。
为什么规则 3 存在:deliveredForDistinctId投递标记
两个移动 SDK 都会在待注册状态旁边持久化一个投递标记(deliveredForDistinctId),当存储的 token、app_id、distinct id 三者全部匹配时跳过请求。标记在任何 2xx 响应时写入,并且跨进程重启存活——这正是设备不在每次启动都重发的原因。
但这个机制有一个副作用:设备在项目未配置期间注册,也收到 200、也写下了"成功投递"标记——可服务端从未保存过这个 token。服务端没有任何办法撤销这件事:没有响应能到达一个已经停止询问的设备。唯一能触发重新注册的路径只剩:
- token 轮换(rotation);
identify()切换到一个不同的 distinct id;- 应用重装。
所以结论很硬核:一个在项目配置之前注册过的设备,会一直不可达,直到它升级到实现了规则 3 的 SDK。这就是规则 3 必须随 SDK 发布、而不能指望服务端兜底的原因。
服务端可观测性:三个可验证的观测点
原文档列出,且能在 push_subscriptions.py 中找到对应定义的观测手段:
| 指标 / 日志 | 标签 | 含义 |
|---|---|---|
push_subscription_rejection | code(method_not_allowed、invalid_json、missing_api_key、invalid_api_key、missing_fields、invalid_platform、identity_verification_failed、capture_failed等)、method(POST/DELETE/other) | 被拒绝的请求计数 |
push_subscription_discarded | reason(当前仅no_integration) | 被"确认但不存储"的注册计数 |
push_subscription_discarded日志行 | team_id、app_id等 | 命名出背后项目身份的日志 |
每分钟每团队一条日志的设计
discard 是端点最常态的流量(大多数团队没有推送配置),如果每条请求都打日志,就等于无限复述同一件事。因此日志用_is_first_discard_in_window(push_subscriptions.py)做限流:每个团队每个 60 秒窗口只记一条(_DISCARD_LOG_WINDOW_SECONDS = 60),计数则每次都累加。细节值得注意:
- 窗口键只含
team_id和窗口号,不含请求方控制的app_id,防止公开 token 铸造无限缓存条目; - 缓存失败时fail open(返回 True 允许记录)——丢失缓存绝不能丢掉唯一能命名项目的日志;
- 测试 test_discard_is_logged_once_per_window_and_counted_every_time 验证:同一团队 5 次注册 + 2 次不同
app_id注册,日志只有 1 条,计数 +7。
其他可观测细节
_rejection_response会对method标签做基数收口(非POST/DELETE统一为other),因为任意 HTTP 动词都能到达该视图,避免 Prometheus 标签爆炸;- User-Agent 按
posthog-<name>/<version>解析出 SDK 身份(如posthog-android/3.59.0),让拒绝日志可归因到具体 SDK 版本; - 无效项目 token 只做 HMAC-SHA256 指纹记录(
api_key_fingerprint),原始 token 永不进入日志(测试 test_invalid_token_rejection_attributes_the_sdk_and_never_logs_the_raw_token 断言日志中不出现原始 token 字符串); - 无效 token 的 team 查询结果会被进程内
TTLCache(2048 条、60 秒 TTL)负缓存,避免配置错误的客户端以重试频率无限打击 Postgres——TTL 刻意设短,因为 token 可能因项目重建而重新变有效(push_subscriptions.py 的注释完整阐述了这一权衡)。
测试如何锁定这套契约
除上文提到的用例之外,test_push_subscriptions.py 还覆盖了这些协议边界,可作为实现自检清单:
- Android / iOS token 各自注册成功(
test_register_android_token/test_register_ios_token),断言$set的 property key 正确; - iOS 设备也可以注册 Firebase token(
test_ios_device_registers_a_firebase_token):provider 由app_id决定而非设备平台,所以走 Firebase 通道的 iOS 应用用project_id注册; - 注销无集成仍执行
$unset(test_unregister_without_integration_still_unsets):这与服务端"DELETE 不因无集成短路"的行为对应; - team 隔离(
test_team_isolation):A 团队配置的app_id在 B 团队注册会被丢弃; - 验证模式优先级(
test_strictest_mode_wins_when_two_integrations_share_an_app_id):app_id可同时匹配多个集成(project_id/bundle_id无唯一约束),模式解析 fail closed,取最严格者; - 缓存路径正确性(
test_configured_app_registers_on_the_cached_path):若快捷判断误判,会把团队应得的注册丢弃,且设备因 200 永不重试——所以缓存路径必须有测试保护。
小结
PostHog 的推送订阅注册协议可以概括为一句设计哲学:把"未配置"当作常态,而不是错误。服务端以 200 +stored:false温和丢弃无法消费的注册,以push.appIds远程配置键把"可接受注册的列表"提前交给 SDK,而 SDK 端则要精确区分键的 absent/empty、遵守三条注册规则,并把列表持久化到磁盘,让拦截效果在第一次启动就生效。如果你正在实现移动 SDK 的 push capture,或是在自托管部署上排查推送注册问题,本文列出的源码路径、错误码表和测试用例就是最可靠的参考依据。
关键文件索引:
- 端点实现:products/messaging/backend/api/push_subscriptions.py
- 远程配置构造:products/messaging/backend/remote_config.py
- 远程配置组装与集成变更重建:posthog/models/remote_config.py
- 端点测试:products/messaging/backend/api/test/test_push_subscriptions.py
- 配置构造测试:products/messaging/backend/tests/test_remote_config.py
- 路由:posthog/urls.py
- 原设计文档:docs/internal/push-subscription-registration.md
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考