news 2026/9/14 2:11:26

PostHog 推送订阅注册机制解析:`/api/push_subscriptions/`、`push.appIds` 远程配置与移动 SDK 端到端协议

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostHog 推送订阅注册机制解析:`/api/push_subscriptions/`、`push.appIds` 远程配置与移动 SDK 端到端协议

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 或自托管部署中正确实现、排查推送注册链路。

整体流程:一次推送注册是如何发生的

端到端数据流

整个推送注册协议由一条链路串起:

  1. 开启 push capture 的移动 SDK 在 App 启动时,向/api/push_subscriptions/发起POST,提交设备 token;
  2. 服务端用请求中的api_key(项目 token)解析出 team;
  3. 服务端将app_id解析为团队配置的 Firebase 或 APNs 集成;
  4. 若解析成功,token 被加密后以 person property$device_push_subscription_<app_id>的形式写入用户画像(person profile);
  5. 若解析失败(团队根本没有为该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_exemptpush_subscriptions视图,同时处理POSTDELETE,并显式支持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_keystring公开项目 token,用于解析 team;缺失返回401
distinct_idstring当前用户的 distinct id,token 将绑定到该用户
device_tokenstringFirebase/APNs 设备 token,加密后存储
platformstring必须是androidiosVALID_PLATFORMS
app_idstringFirebaseproject_id或 APNsbundle_id
identity_tokenstring身份校验开启(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路径)

codeHTTP 状态触发条件
method_not_allowed405POST/DELETE/OPTIONS方法
request_too_large413body 超过 16 KiB
invalid_json400body 不是合法的 JSON 对象
missing_api_key401未提供项目 token
invalid_api_key401token 解析不到任何 team
missing_fields400distinct_id/device_token/platform/app_id缺失、为空或类型错误
invalid_platform400platform不是android/ios
identity_verification_failed401集成要求required校验但 token 无效或缺失
capture_failed500capture_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 是刻意为之,理由有两条(原文档原话即源码注释的核心):

  1. 语义正确:请求本身没有任何无效之处——token、字段、方法全部合法,只是该团队此刻没有对应的集成。这是"账户状态"而非"请求错误";
  2. 成本正确:推送是每个项目可选(opt-in)的,而 SDK 无法感知项目是否开启,因此绝大多数设备的注册请求在多数团队眼里都是"无集成可注册"。若返回 4xx,这些请求会占满所有监控非 2xx 比率的告警系统,把这个端点变成一个永不停歇的"错误洪流"(error firehose)。

服务端把这种"确认收到但不存储"称为 discard(丢弃),并有专门的埋点与日志计数(见"可观测性"小节)。

服务端的快速路径:_configurable_app_ids缓存

由于"该团队从未配置过这个app_id"是常态,每次请求都走一次 JSONB 查询(_find_integrationsconfig__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.configJSONField,理论上可以存放任意 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中与其他模块(errorTrackingsessionRecordinglogs等)一起组装进远程配置负载。

当一个 Firebase/APNs 集成被创建或删除时,push_integration_changed信号接收器(posthog/models/remote_config.py)会在事务提交后(transaction.on_commit)重建该团队的远程配置,无需等待一次无关的写入。该接收器有两个精细的过滤:

  1. 只处理kindPUSH_APP_ID_CONFIG_KEYS中的集成——OAuth token 刷新等无关集成变更不会触发全量重建;
  2. 只处理config字段实际变化的保存(检查update_fields)——因为 APNs 集成创建过程本身会多次 save(错误信息、created_by等),只有config才影响appIds载荷。

SDK 必须实现的三条规则

原文档给出了移动 SDK 端必须遵守的完整注册规则,缺一不可:

  1. push.appIds键缺失(absent)—— 按旧行为照常注册。这保证对旧版服务端的兼容;
  2. app_id不在appIds—— 完全跳过请求。既然服务端会丢弃它,就没有任何理由发送;
  3. app_id出现在appIds中、而此前它不在—— 清除本地"该 token 已投递"的记录,然后注册。

规则 3 是最容易被漏掉的一条。它的存在理由:一个此前未配置推送的项目后来开启了推送,但设备曾在未配置期间注册过(并收到了 200,本地记下了"已投递")。如果不清除这个本地标记,这些设备会继续跳过注册——而服务端从未保存过它们的 token——结果就是"项目刚打开推送,却永远触达不到已有的设备"。测试 test_register_without_integration_returns_200_and_discards 展示了"未配置时注册被丢弃"这一前提,正是规则 3 要修复的后续状态。

列表必须落盘缓存

注册发生在启动时,而远程配置是异步解析的。冷启动时,SDK 通常先到达注册点、/config还没返回。只查阅"本次新拉取的列表"的 SDK,依然会发出本该跳过的请求——拦截效果要等到第二次启动才生效,而不是第一次。

因此正确的做法是:push 片段到达时立即持久化,下次启动时、在第一次注册尝试之前预加载

原文档给出了可参考的既有模式:Android 端errorTracking就是如此——processErrorTrackingConfig写入config.cachePreferencespreloadErrorTrackingConfig在启动时恢复;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_rejectioncodemethod_not_allowedinvalid_jsonmissing_api_keyinvalid_api_keymissing_fieldsinvalid_platformidentity_verification_failedcapture_failed等)、methodPOST/DELETE/other被拒绝的请求计数
push_subscription_discardedreason(当前仅no_integration被"确认但不存储"的注册计数
push_subscription_discarded日志行team_idapp_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 tokentest_ios_device_registers_a_firebase_token):provider 由app_id决定而非设备平台,所以走 Firebase 通道的 iOS 应用用project_id注册;
  • 注销无集成仍执行$unsettest_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 2:11:21

企业级Agent平台深度拆解:从超级个体到超级团队的落地实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 2:09:55

PHP仿花瓣网源码拆解:从数据模型到部署与安全审计

简介&#xff1a;这是一份基于PHP开发的高仿花瓣网整站源码&#xff0c;面向希望学习PHP全栈开发或构建图片灵感采集类网站的开发者。资源包围绕用户注册登录、图片采集上传、分类管理与收藏等核心功能展开&#xff0c;覆盖了MVC分层、数据库交互、模板渲染、RESTful API设计以…

作者头像 李华
网站建设 2026/9/14 2:09:27

天津新建小区壁挂炉首次启动不会调试,欧米到家提供采暖设置及故障检测服务

文章简介天津壁挂炉出现不点火、不供暖、水压下降、热水忽冷忽热、漏水、异响或故障代码时&#xff0c;应结合燃气供应、采暖水路、点火系统和控制系统综合判断。欧米到家为天津用户提供壁挂炉检测、维修、清洗保养、配件更换及使用调试等服务。天津用户可通过电话或官网预约壁…

作者头像 李华
网站建设 2026/9/14 2:09:27

Lithe-IDEA:专为Spring Boot开发优化的轻量级开源Java IDE

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 2:09:02

OpenClaw 配 TaoToken:Docker 沙箱里安全调用模型 API

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华