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/posthogPostHog 的推送通知采用按项目显式开启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.pyopt_slash_path(api/push_subscriptions, push_subscriptions)视图实现位于 products/messaging/backend/api/push_subscriptions.pycsrf_exempt的push_subscriptions视图同时处理POST与DELETE并显式支持OPTIONS预检。存储的底层实现注册成功的落库并不是直接写数据库而是通过capture_internal生成一条$set事件进入摄取管道push_subscriptions.pyif 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_iddevice token 在存储前经过EncryptedFieldMixin.encrypt()加密Fernet token原始 token 不会以明文进入事件流——这一点由 test_token_is_encrypted 测试锁定断言加密值不等于原始 token 且为非空字符串DELETE登出执行$unset且$unset一个不存在的属性是 no-op因此注销天然幂等同时注销不校验客户端上次持有的 token 是否与存储值一致只要app_id相同就清除该订阅。请求/响应契约与字段说明POST 请求字段字段类型必填说明api_keystring是公开项目 token用于解析 team缺失返回401distinct_idstring是当前用户的 distinct idtoken 将绑定到该用户device_tokenstring是Firebase/APNs 设备 token加密后存储platformstring是必须是android或iosVALID_PLATFORMSapp_idstring是Firebaseproject_id或 APNsbundle_ididentity_tokenstring否身份校验开启optional/required时所需的短期签名 token请求体上限为 16 KiBMAX_BODY_BYTES 16 * 1024且在解压之前检查长度防止压缩炸弹在load_data_from_request解压时膨胀为内存耗尽攻击见 test_oversized_body_is_rejected_before_parsing。SDK 可以发送 gzip 压缩的 JSON bodycontent-encoding: gzip会被正确解压test_gzip_compressed_body。响应语义注册成功200body 为{distinct_id: ..., platform: ...}token 已作为 person property 存储未配置集成POST200body 为{stored: false, push_enabled: false}不存储详见下文注销成功200body 为{distinct_id: ..., platform: ...}属性被$unset错误响应统一的异常体格式携带code字段。错误码一览源码_rejection_response路径codeHTTP 状态触发条件method_not_allowed405非POST/DELETE/OPTIONS方法request_too_large413body 超过 16 KiBinvalid_json400body 不是合法的 JSON 对象missing_api_key401未提供项目 tokeninvalid_api_key401token 解析不到任何 teammissing_fields400distinct_id/device_token/platform/app_id缺失、为空或类型错误invalid_platform400platform不是android/iosidentity_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 是刻意为之理由有两条原文档原话即源码注释的核心语义正确请求本身没有任何无效之处——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_idspush_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_idAPNs 集成贡献其bundle_id该键始终存在包括作为空列表{appIds: []}列表中元素去重并排序sorted(set(...))保证输出稳定可缓存。absent 与 empty 是两回事这是本协议最容易踩坑的语义陷阱absent键不存在说明服务端早于该键的引入SDK不能下任何结论必须照常尝试注册empty空列表说明服务端明确声明该团队没有配置任何推送。一个把 absent 当作 empty 处理的 SDK会停止向所有旧版自托管部署注册——而这些部署的用户将永远收不到推送。源码实现build_push_config列表的构造实现在 products/messaging/backend/remote_config.pyPUSH_APP_ID_CONFIG_KEYS {firebase: project_id, apns: bundle_id} def build_push_config(team: Team) - dict: integrations Integration.objects.filter(teamteam, kind__inlist(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.cachePreferencespreloadErrorTrackingConfig在启动时恢复iOS 端镜像同样的做法。push 片段应照抄该模式。这里还要澄清一个与规则 1 的交互冷启动且无任何缓存≠ absent 键。无缓存意味着SDK 从未听到过这个服务器的声音此时必须注册——这正是规则 1 的场景只有缓存中的空列表才意味着跳过。换句话说判断依据永远是服务端到底说了什么而不是客户端本地有没有数据。为什么规则 3 存在deliveredForDistinctId投递标记两个移动 SDK 都会在待注册状态旁边持久化一个投递标记deliveredForDistinctId当存储的 token、app_id、distinct id 三者全部匹配时跳过请求。标记在任何 2xx 响应时写入并且跨进程重启存活——这正是设备不在每次启动都重发的原因。但这个机制有一个副作用设备在项目未配置期间注册也收到 200、也写下了成功投递标记——可服务端从未保存过这个 token。服务端没有任何办法撤销这件事没有响应能到达一个已经停止询问的设备。唯一能触发重新注册的路径只剩token 轮换rotationidentify()切换到一个不同的 distinct id应用重装。所以结论很硬核一个在项目配置之前注册过的设备会一直不可达直到它升级到实现了规则 3 的 SDK。这就是规则 3 必须随 SDK 发布、而不能指望服务端兜底的原因。服务端可观测性三个可验证的观测点原文档列出且能在 push_subscriptions.py 中找到对应定义的观测手段指标 / 日志标签含义push_subscription_rejectioncodemethod_not_allowed、invalid_json、missing_api_key、invalid_api_key、missing_fields、invalid_platform、identity_verification_failed、capture_failed等、methodPOST/DELETE/other被拒绝的请求计数push_subscription_discardedreason当前仅no_integration被确认但不存储的注册计数push_subscription_discarded日志行team_id、app_id等命名出背后项目身份的日志每分钟每团队一条日志的设计discard 是端点最常态的流量大多数团队没有推送配置如果每条请求都打日志就等于无限复述同一件事。因此日志用_is_first_discard_in_windowpush_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 查询结果会被进程内TTLCache2048 条、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_tokenprovider 由app_id决定而非设备平台所以走 Firebase 通道的 iOS 应用用project_id注册注销无集成仍执行$unsettest_unregister_without_integration_still_unsets这与服务端DELETE 不因无集成短路的行为对应team 隔离test_team_isolationA 团队配置的app_id在 B 团队注册会被丢弃验证模式优先级test_strictest_mode_wins_when_two_integrations_share_an_app_idapp_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),仅供参考