PostHog 仪表盘后端契约与运维:软删除生命周期、SSE 渐进加载与 REST/MCP 双契约
本文基于 PostHog 仓库中仪表盘技能的参考文档 backend-contracts-and-operations.md,系统梳理 PostHog Dashboard 平台在后端侧需要遵守的六类契约:资源软删除生命周期、项目树与跨项目迁移、列表与产品内嵌仪表盘、REST/OpenAPI/MCP 多消费者契约、SSE 流式渐进交付,以及限额、审计与订阅去重等运维约束。读完本文,你将能结合 Dashboard 模型、DashboardTile 模型 与 Dashboard API 的源码证据,理解每条契约背后的实现依据,并在改动仪表盘后端时预判其影响面。
1. 资源生命周期:Dashboard 与 Tile 都是软删除资源
原文档的第一条核心规则是:仪表盘和图块(tile)使用软删除,不要用硬删除替代它。这条规则在源码中有完整的落地形态。
1.1 双 Manager 设计:默认视图与恢复路径分离
Dashboard 与 DashboardTile 都定义了成对的 Manager:
Dashboard.objects是DashboardManager,其get_queryset()直接exclude(deleted=True),即正常业务查询永远看不到已删除的仪表盘,见 dashboard.py#L38-L40;Dashboard.objects_including_soft_deleted是不过滤删除标记的RootTeamManager,专门支撑“恢复(restore)”路径,见 dashboard.py#L108-L109;DashboardTile同理:objects排除了deleted=True且排除dashboard__deleted=True的行,objects_including_soft_deleted则暴露全部行,见 dashboard_tile.py#L111-L112。
这个模式意味着:任何恢复类操作(撤销删除、复制回原位置)必须显式走 objects_including_soft_deleted。源码中大量注释提醒开发者,默认 Manager 排除 deleted=True 后,通过反向关联(如 tile.insight)遍历会得到 None,必须用未过滤的 Manager 直接查询,见 dashboard.py#L2287-L2304。
1.2 删除、恢复与级联细节
- 删除仪表盘可级联删除 insight:当请求显式要求时,删除仪表盘会连同其 tile 关联的 Insight 一起软删除;API 层还处理了“恢复仪表盘时把被一起删掉的 insight 也恢复”的逻辑(
_undo_delete_related_tiles),见 dashboard.py#L2287-L2315。 - 删除 tile 保留底层内容:
DashboardTile是关联行而非内容本体,删除 tile 只是把关联行标记deleted=True,其引用的Insight、Text、ButtonTile或DashboardWidget若被其他关系引用则继续存活。copy_to_dashboard的注释也印证了这一点:复制 tile 时若目标已存在同一内容的软删除行,走“解除删除”而不是二次插入,以避免唯一约束冲突,见 dashboard_tile.py#L219-L266。 - 移动/复制必须保持“一个 tile 恰好一个关联对象”的约束与目标权限:模型层用四条条件唯一约束加一条
CheckConstraint(dash_tile_exactly_one_related_object)保证 insight/text/button_tile/widget 四选一,见 dashboard_tile.py#L114-L141。API 层move_tile会检查目标仪表盘在同一 project 内、调用者对目标有编辑权限,并校验公开链接不会暴露调用者无权执行的查询,见 dashboard.py#L2832-L2880。模型层的prepare_move_to_dashboard还会清理目标端占用同一唯一键的软删除行,遇到非删除行则拒绝移动,见 dashboard_tile.py#L190-L217。 - 多行变更使用窄的
transaction.atomic()块:move_tile、copy_tile、仪表盘复制等 API 都在单一with transaction.atomic():内完成“改 tile 归属 + 写日志”等组合操作,见 dashboard.py#L2864、dashboard.py#L2915。
1.3 批量更新绕过信号:必须显式同步依赖资源
这是原文档中一条很容易踩坑的规则:Django 的 bulk_update() / QuerySet.update() 不触发模型信号,因此依赖信号完成的副作用(如项目树同步)不会发生,必须在批量更新后显式补齐。仓库源码中留下了直接的注释证据:删除关联 insight 时先 update(deleted=True),随后手工调用 _sync_filesystem_for_insights 重新同步 FileSystem,否则“Recents 侧边栏会残留陈旧条目,点进去是 404”;恢复路径上 bulk_update 后同样补一次同步,见 dashboard.py#L2276-L2315。
改动生命周期行为前,原文档要求先阅读 dashboard_tile.py 和 dashboard.py 两个文件——本文上述结论均可在这两个文件中逐条核对。
2. 项目树、标签与资源迁移(resource transfer)
原文档指出:Dashboard 不只是 API 里的一行数据,它是项目树(project tree)资源。这一点在模型定义中一目了然:Dashboard 继承了 FileSystemSyncMixin、ModelActivityMixin 与 RootTeamMixin,见 dashboard.py#L43。由此产生一组运维约束:
-
创建、改名、移动、删除、恢复都可能更新文件系统条目。
get_file_system_representation()返回type="dashboard"的文件系统表示,且creation_mode == "template"的模板仪表盘标记为should_delete=True(不出现在项目树中),见 dashboard.py#L139-L152。 -
共享响应中保持文件夹数据私有:共享/嵌入等受限表面不能把项目树结构暴露给未授权查看者。
-
自定义序列化路径绕过 mixin 时,要保持标签行为一致:API 层对标签采用独立的创建/记录逻辑(标签作为全局 tag 关系单独建立,并单独记录 Dashboard 作用域的活动日志),见 dashboard.py#L1556 与 dashboard.py#L2457-L2459。
-
新增可跨项目复制的持久化字段时,要更新 resource-transfer visitor。visitor 声明了
excluded_fields,即“不随迁移走的派生状态”。当前DashboardVisitor排除的正是分享、刷新、访问、缓存类字段:class DashboardVisitor( ResourceTransferVisitor, kind="Dashboard", excluded_fields=[ "data_color_theme_id", "data_color_theme", "analytics_dashboards", "last_refresh", "last_accessed_at", "share_token", "is_shared", ], ):见 dashboard.py(visitor)。
DashboardTileVisitor则排除filters_hash、last_refresh、refreshing、refresh_attempt四个纯缓存/刷新状态字段,见 dashboard_tile.py(visitor)。这与原文档“排除分享、刷新、访问、缓存字段”的表述逐一对应。 -
校验迁移结果:迁移后的仪表盘必须有合法的 team 归属,且不能携带源项目独有的标识符。
3. 列表、发现与产品创建的仪表盘
原文档强调:Dashboard 列表是一套与详情独立的契约,必须保留 pinned 排序、搜索、标签、文件夹数据与未列出(unlisted)仪表盘的排除行为。源码中的列表契约集中在 DashboardViewSet:
- 基础排序即
order_by("-pinned", "name"),配合条件索引idx_dashboard_deleted_team_id(-pinned, name, deleted, team_id,condition=deleted=False),使排序走索引而非逐行查询,见 dashboard.py#L113-L120 与 dashboard.py#L2459; filter_queryset统一处理search(带MAX_SEARCH_LENGTH上限校验)、tags与folder参数;文件夹过滤通过相关子查询匹配posthog_file_system中“直接位于该文件夹下”的条目,利用posthog_fs_team_s_typeref索引完成,避免了对每行仪表盘单独发起文件系统或标签查询,见 dashboard.py#L2474-L2502;- 列表请求额外用
DashboardBasicSerializer(而非详情序列化器)降低开销,见 dashboard.py#L2464-L2465。
读操作会写 last_accessed_at。每次仪表盘读取都会调用 record_dashboard_view(dashboard, access_method) 记录访问(员工伪装身份时跳过),访问方法用于区分人工/共享/嵌入/API 来源,见 access.py#L43 与 dashboard.py#L2709-L2714。因此原文档提醒:新增轮询、嵌入等读路径时,要评估写放大。
产品创建的未列出仪表盘:Dashboard.CreationMode.UNLISTED(注释为 “Product dashboards (e.g. AI observability) - hidden from general lists, accessed via tag queries”)就是该契约的落点,见 dashboard.py#L54-L57。这类仪表盘需要稳定的查找数据和并发创建保护(同产品重复创建同一仪表盘时去重),并且除非产品明确暴露,否则不出现在普通列表中。
3.1 持久化的列表状态
原文档给出两条边界规则:持久化的列表配置要与仪表盘元数据分开存储;只持久化能重建列表的值,不持久化临时 UI 状态。仓库中对应的真实模型是 dashboard_saved_view.py(迁移 0016_dashboardsavedview.py),API 在 dashboard_saved_view.py 与 test_dashboard_saved_views.py 中有独立测试,可作为该契约的验证参照。
4. API、Schema 与 MCP 契约
原文档把仪表盘行为定义为同时服务 REST、前端生成类型与 MCP 三类消费者,并给出 7 步操作清单。结合仓库可把它落到具体文件与命令:
- 每个新增请求/响应字段都要加序列化器 schema 注解(DRF
@extend_schema/SerializerMethodField注解),OpenAPI 输出测试见 test_dashboard_openapi.py; - 契约变更后运行
hogli build:openapi:该任务族(build:openapi-schema、build:openapi-types、build:openapi-mcp等)定义在 hogli.yaml#L513-L545; - MCP 操作定义在 products/dashboards/mcp/tools.yaml:文件头注释说明工具条目由 OpenAPI schema 脚手架生成(
pnpm --filter=@posthog/mcp run scaffold-yaml -- --sync-all),启用工具时必须写明scopes与annotations。例如dashboard-create工具声明了dashboard:write作用域和readOnly: false, destructive: false, idempotent: false注解,见 tools.yaml#L10-L22; - OpenAPI 操作或工具定义变化后重新生成 MCP 代码(同上 scaffold 命令族);
- 检查 API 作用域:读、写、执行查询使用不同 scope,API 代码中通过
required_scopes声明,如move_tile要求dashboard:write(dashboard.py#L2826),subscribe_nudge同样要求dashboard:write(dashboard.py#L3694); - 一次性 filter/variable 覆盖保持非持久化:
stream_tiles与查询端点均通过FILTERS_OVERRIDE_PARAM/VARIABLES_OVERRIDE_PARAM接收覆盖参数,除非端点显式持久化,否则不写回模型; - 保持共享 token 规则:共享请求忽略仪表盘级 filter 与 variable 覆盖。
原文档还特别指出:MCP 响应可以有意省略 REST 端点返回的字段。这在 tools.yaml 中有直接体现——dashboard-create 用 exclude_params 排除 creation_mode、last_refresh、last_accessed_at、deleted 等字段,描述中明确“返回的 tiles 省略 insight 结果以节省上下文”,见 tools.yaml#L46-L58。因此REST 与 MCP 行为必须分开测试,不能假设两者响应字段一致。
5. SSE 流式交付:stream_tiles 的渐进加载契约
stream_tiles 端点(/projects/:id/dashboard/:id/stream_tiles)通过 Server-Sent Events 先发送仪表盘元数据、再逐个发送 tile。原文档的 8 条契约在 dashboard.py#L2684-L2825 中逐条可见:
- 元数据先于其余 tile 发出:实现上甚至把前 2 个 tile 直接内嵌进
type: "metadata"事件,减少首屏往返; - tile 顺序对选定的布局尺寸保持稳定:按
layouts[layoutSize].y再x排序(无效值回退为sm),layoutSize参数同时接受layout_size兼容别名,见 dashboard.py#L2750-L2757 与 dashboard.py#L2818-L2830;模型层还封装了同语义的DashboardTile.sort_tiles_by_layout(无布局的 tile 以id打破平局,避免数据库返回顺序漂移),见 dashboard_tile.py#L268-L284; - 单个 tile 序列化失败只产生 tile 级错误,不中断整个流:失败时仍发送同形状的
type: "tile"事件、内部带error字段——源码注释解释了为什么不能发type: "error":前端会把该事件路由到onError(临时 toast)并丢弃 tile,而 tile 级错误对象能渲染成占位错误块,见 dashboard.py#L2780-L2805; - 全部 tile 发送后发出完成事件:
{"type": "complete"},见 dashboard.py#L2806-L2809; - 同时支持 ASGI 与 WSGI 交付路径:根据
settings.SERVER_GATEWAY_INTERFACE决定直接消费 async generator 还是用async_to_sync包装,见 dashboard.py#L2814-L2821; - 不要在 async generator 里直接做数据库工作:实现把每个 tile 的同步序列化包进
database_sync_to_async(..., thread_sensitive=True),数据库操作全部前置到进入生成器之前完成(对象获取、访问记录、序列化器上下文),见 dashboard.py#L2710-L2758; - 上下文标记计算面:流式路径写入
ComputeSurface.DASHBOARD_STREAM,区别于详情的DASHBOARD_DETAIL与变更的DASHBOARD_MUTATE(后者在 dashboard.py#L2470-L2476 按 action 动态设置); - 改刷新默认值前检查
chained_dashboard_tile_refresh门控与所有相关ComputeSurface:仪表盘详情路径中该特性按组织维度feature_enabled判定,见 dashboard.py#L2353-L2358。
原文档的收尾建议同样值得作为设计原则:把普通 retrieve 端点与 stream_tiles 视为两条独立的读契约分别设计与测试。前端消费侧生成的调用见 api.ts#L866-L867。
6. 限额、门控与滥用抵抗
原文档要求:新增任何创建、加载或执行仪表盘工作的路径之前,先检查限额。仓库中的具体锚点:
- 仪表盘创建数量限额:创建时统计
Dashboard.objects.filter(team_id=..., deleted=False).count()(注意软删除的行不计入配额)并调用check_count_limit(team, LimitKey.MAX_DASHBOARDS_PER_TEAM, ...),见 dashboard.py#L1546-L1552; - widget 数量限额:
_check_dashboard_widget_count_limit在复制/添加 widget tile 前检查,见 dashboard.py#L1323-L1324 与 dashboard.py#L1769; - 公共与嵌入访问不得绕过配额和产品访问检查:widget tile 操作会检查
dashboard_widgets_enabled特性与_check_widget_tile_product_access,见 dashboard.py#L2854-L2862; - 请求负载、tile ID、filter 尺寸、分页在到达查询执行前必须被约束:列表搜索长度受
MAX_SEARCH_LENGTH校验,见 dashboard.py#L2478-L2483。
原文档还指出:默认分页加载,仅当结果有界且功能必需时才全量加载;widget 相关的限额、门控与节流细节应参阅 manage-dashboard-widgets 技能。
7. 审计、分析、订阅与可观测性
原文档把仪表盘变更定位为“产品事件 + 审计事件”,并列出五条保持项,每条都能在源码中验证:
- 保持模型活动日志:
Dashboard继承ModelActivityMixin(dashboard.py#L43),产品侧还有 activity_logging.py; - 保持用户行为事件:tile/仪表盘的创建、更新、删除与 filter 变更对应
posthog分析事件与活动日志记录; - 访问与缓存指标按 human / shared / embedded / API 分类:
record_dashboard_view以dashboard_access_method区分访问来源,stream 与 retrieve 路径都会传递该值,见 access.py#L43; - 保持端点监控并评估 SLO 覆盖:
DashboardViewSet的各 action 均包裹@tracer.start_as_current_span(如 dashboard.py#L2504、dashboard.py#L2531),列表路径还额外记录dashboard.search.result_count/dashboard.search.empty两个 span 属性用于调优搜索相似度阈值,见 dashboard.py#L2505-L2512; - 订阅与订阅提示(subscribe nudge):
subscribe_nudge端点实现了原文档强调的双重去重——先用safe_cache_add写入缓存哨兵dashboard_subscribe_nudge:{user}:{dashboard}做短期去重,再用has_been_dispatched(...)做持久化兜底(缓存被清空或 Redis 故障时不能放行第二次);创建通知失败或无接收人时还会回滚哨兵,避免“一次机会”被静默烧掉,见 dashboard.py#L3694-L3742 与测试 test_dashboard_subscribe_nudge.py。
8. 后端测试矩阵
原文档最后给出了一张“变更类型 → 测试边界”矩阵,建议将其作为改动仪表盘后端时的回归清单:
| 变更类型 | 应覆盖的测试边界 |
|---|---|
| 持久化模型字段 | 迁移、序列化器、OpenAPI、生成类型、MCP schema |
| 持久化列表状态 | 适用载荷、稳定分页、列表位置、权限、MCP 决策 |
| 创建或删除 | 团队配额、软删除、活动日志、文件系统同步、恢复 |
| 移动、复制或重复 | 源/目标访问权限、事务回滚、tile 唯一性 |
| 读端点 | REST 与流式行为、共享数据净化、缓存策略、错误载荷 |
| 查询端点 | 作用域、节流、访问方式、缓存结果、取消、部分失败 |
| 模板或迁移 | 旧载荷、源项目专属引用、目标团队、被排除的派生字段 |
| 订阅路径 | 权限、重复投递、缓存丢失、通知失败 |
矩阵中涉及的测试资产在仓库中已有对应物:OpenAPI 契约测试 test_dashboard_openapi.py、访问权限测试 test_access.py、widget 节流测试 test_widget_query_throttle.py 与订阅提示测试 test_dashboard_subscribe_nudge.py。
小结
这套后端契约的共同主题是:仪表盘是一个被多方消费的活资源——它同时活在项目树、文件系统索引、REST/OpenAPI/MCP 三种接口、SSE 流与审计/订阅链路中。原文档的每一条规则(软删除不硬删、批量更新后显式同步、列表与详情分治、共享请求忽略覆盖参数、MCP 与 REST 分开测、流式单 tile 失败不拖垮全流、双去重订阅提示)都对应 products/dashboards/backend/ 中可核对的源码实现。改动前对照本文的源码路径逐条确认影响面,是控制这类“多表面”功能回归成本的最直接方式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0634
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java01
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java00
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00