首页
/ PostHog 仪表盘后端契约与运维:软删除生命周期、SSE 渐进加载与 REST/MCP 双契约

PostHog 仪表盘后端契约与运维:软删除生命周期、SSE 渐进加载与 REST/MCP 双契约

2026-09-09 15:40:12作者:晏闻田Solitary

本文基于 PostHog 仓库中仪表盘技能的参考文档 backend-contracts-and-operations.md,系统梳理 PostHog Dashboard 平台在后端侧需要遵守的六类契约:资源软删除生命周期、项目树与跨项目迁移、列表与产品内嵌仪表盘、REST/OpenAPI/MCP 多消费者契约、SSE 流式渐进交付,以及限额、审计与订阅去重等运维约束。读完本文,你将能结合 Dashboard 模型DashboardTile 模型Dashboard API 的源码证据,理解每条契约背后的实现依据,并在改动仪表盘后端时预判其影响面。

1. 资源生命周期:Dashboard 与 Tile 都是软删除资源

原文档的第一条核心规则是:仪表盘和图块(tile)使用软删除,不要用硬删除替代它。这条规则在源码中有完整的落地形态。

1.1 双 Manager 设计:默认视图与恢复路径分离

DashboardDashboardTile 都定义了成对的 Manager:

  • Dashboard.objectsDashboardManager,其 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,其引用的 InsightTextButtonTileDashboardWidget 若被其他关系引用则继续存活。copy_to_dashboard 的注释也印证了这一点:复制 tile 时若目标已存在同一内容的软删除行,走“解除删除”而不是二次插入,以避免唯一约束冲突,见 dashboard_tile.py#L219-L266
  • 移动/复制必须保持“一个 tile 恰好一个关联对象”的约束与目标权限:模型层用四条条件唯一约束加一条 CheckConstraintdash_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_tilecopy_tile、仪表盘复制等 API 都在单一 with transaction.atomic(): 内完成“改 tile 归属 + 写日志”等组合操作,见 dashboard.py#L2864dashboard.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.pydashboard.py 两个文件——本文上述结论均可在这两个文件中逐条核对。

2. 项目树、标签与资源迁移(resource transfer)

原文档指出:Dashboard 不只是 API 里的一行数据,它是项目树(project tree)资源。这一点在模型定义中一目了然:Dashboard 继承了 FileSystemSyncMixinModelActivityMixinRootTeamMixin,见 dashboard.py#L43。由此产生一组运维约束:

  1. 创建、改名、移动、删除、恢复都可能更新文件系统条目get_file_system_representation() 返回 type="dashboard" 的文件系统表示,且 creation_mode == "template" 的模板仪表盘标记为 should_delete=True(不出现在项目树中),见 dashboard.py#L139-L152

  2. 共享响应中保持文件夹数据私有:共享/嵌入等受限表面不能把项目树结构暴露给未授权查看者。

  3. 自定义序列化路径绕过 mixin 时,要保持标签行为一致:API 层对标签采用独立的创建/记录逻辑(标签作为全局 tag 关系单独建立,并单独记录 Dashboard 作用域的活动日志),见 dashboard.py#L1556dashboard.py#L2457-L2459

  4. 新增可跨项目复制的持久化字段时,要更新 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_hashlast_refreshrefreshingrefresh_attempt 四个纯缓存/刷新状态字段,见 dashboard_tile.py(visitor)。这与原文档“排除分享、刷新、访问、缓存字段”的表述逐一对应。

  5. 校验迁移结果:迁移后的仪表盘必须有合法的 team 归属,且不能携带源项目独有的标识符。

3. 列表、发现与产品创建的仪表盘

原文档强调:Dashboard 列表是一套与详情独立的契约,必须保留 pinned 排序、搜索、标签、文件夹数据与未列出(unlisted)仪表盘的排除行为。源码中的列表契约集中在 DashboardViewSet

  • 基础排序即 order_by("-pinned", "name"),配合条件索引 idx_dashboard_deleted_team_id-pinned, name, deleted, team_idcondition=deleted=False),使排序走索引而非逐行查询,见 dashboard.py#L113-L120dashboard.py#L2459
  • filter_queryset 统一处理 search(带 MAX_SEARCH_LENGTH 上限校验)、tagsfolder 参数;文件夹过滤通过相关子查询匹配 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#L43dashboard.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.pytest_dashboard_saved_views.py 中有独立测试,可作为该契约的验证参照。

4. API、Schema 与 MCP 契约

原文档把仪表盘行为定义为同时服务 REST、前端生成类型与 MCP 三类消费者,并给出 7 步操作清单。结合仓库可把它落到具体文件与命令:

  1. 每个新增请求/响应字段都要加序列化器 schema 注解(DRF @extend_schema / SerializerMethodField 注解),OpenAPI 输出测试见 test_dashboard_openapi.py
  2. 契约变更后运行 hogli build:openapi:该任务族(build:openapi-schemabuild:openapi-typesbuild:openapi-mcp 等)定义在 hogli.yaml#L513-L545
  3. MCP 操作定义在 products/dashboards/mcp/tools.yaml:文件头注释说明工具条目由 OpenAPI schema 脚手架生成(pnpm --filter=@posthog/mcp run scaffold-yaml -- --sync-all),启用工具时必须写明 scopesannotations。例如 dashboard-create 工具声明了 dashboard:write 作用域和 readOnly: false, destructive: false, idempotent: false 注解,见 tools.yaml#L10-L22
  4. OpenAPI 操作或工具定义变化后重新生成 MCP 代码(同上 scaffold 命令族);
  5. 检查 API 作用域:读、写、执行查询使用不同 scope,API 代码中通过 required_scopes 声明,如 move_tile 要求 dashboard:writedashboard.py#L2826),subscribe_nudge 同样要求 dashboard:writedashboard.py#L3694);
  6. 一次性 filter/variable 覆盖保持非持久化stream_tiles 与查询端点均通过 FILTERS_OVERRIDE_PARAM / VARIABLES_OVERRIDE_PARAM 接收覆盖参数,除非端点显式持久化,否则不写回模型;
  7. 保持共享 token 规则:共享请求忽略仪表盘级 filter 与 variable 覆盖。

原文档还特别指出:MCP 响应可以有意省略 REST 端点返回的字段。这在 tools.yaml 中有直接体现——dashboard-createexclude_params 排除 creation_modelast_refreshlast_accessed_atdeleted 等字段,描述中明确“返回的 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].yx 排序(无效值回退为 sm),layoutSize 参数同时接受 layout_size 兼容别名,见 dashboard.py#L2750-L2757dashboard.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-L1324dashboard.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. 审计、分析、订阅与可观测性

原文档把仪表盘变更定位为“产品事件 + 审计事件”,并列出五条保持项,每条都能在源码中验证:

  1. 保持模型活动日志Dashboard 继承 ModelActivityMixindashboard.py#L43),产品侧还有 activity_logging.py
  2. 保持用户行为事件:tile/仪表盘的创建、更新、删除与 filter 变更对应 posthog 分析事件与活动日志记录;
  3. 访问与缓存指标按 human / shared / embedded / API 分类record_dashboard_viewdashboard_access_method 区分访问来源,stream 与 retrieve 路径都会传递该值,见 access.py#L43
  4. 保持端点监控并评估 SLO 覆盖DashboardViewSet 的各 action 均包裹 @tracer.start_as_current_span(如 dashboard.py#L2504dashboard.py#L2531),列表路径还额外记录 dashboard.search.result_count / dashboard.search.empty 两个 span 属性用于调优搜索相似度阈值,见 dashboard.py#L2505-L2512
  5. 订阅与订阅提示(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/ 中可核对的源码实现。改动前对照本文的源码路径逐条确认影响面,是控制这类“多表面”功能回归成本的最直接方式。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.94 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
603
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
396
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
527