首页
/ PostHog Dashboard Widget 配置契约与代码生成:从 Pydantic 单一事实源到前端 Zod 的全链路指南

PostHog Dashboard Widget 配置契约与代码生成:从 Pydantic 单一事实源到前端 Zod 的全链路指南

2026-09-09 22:19:28作者:傅爽业Veleda

PostHog 仪表盘 Widget(Dashboard Widget)的 widget.config 形状由一套"单一事实源(Single Source of Truth,SSOT)"机制统一驱动:后端 Pydantic 模型负责运行时校验,并通过 OpenAPI 桥接 REST 接口与 MCP 工具,最终自动生成前端 Zod schema。本文基于仓库内 config-and-codegen.md 展开,结合 products/dashboards/backend/widget_specs/ 下的真实实现,讲清楚整条契约链路:配置模型写在哪里、如何注入 OpenAPI、如何生成前端类型、CI 如何防漂移,以及新增 widget_type 时必须绕开的坑。

读完本文,你将掌握:widget 配置模型的正确改动流程(改 Pydantic → 跑 hogli build:openapi → 提交生成产物)、多态 OpenAPI 与 Zod codegen 的底层原理、枚举名冲突的诊断与修复,以及 8 个高频"踩坑-修复"对照。

何时需要关心这份契约

这份契约是 widget 配置体系的核心枢纽,以下任意场景都会触发对它的一次完整消费:

  • Pydantic 配置变更:修改了 widget_specs/configs.py 中某个 *WidgetConfig 的字段;
  • hogli build:openapi:需要重新生成 OpenAPI 与前端类型;
  • Zod/OpenAPI 漂移:CI 的 schema 一致性测试失败;
  • ENUM_NAME_OVERRIDES:新增 widget_type 后枚举名冲突;
  • MCP config_schema 更新:Agent 工具侧拿到的配置形状需要刷新。

平台整体文件地图见 architecture.md,新类型上线清单见 checklist-new-widget-type.md 第 1–4 节,存量类型迁移路径见 managing-existing-widgets.md 的 Config schema migration 一节

Config contract:widget_specs/ 是唯一的契约源头

后端 Pydantic 模型是整个契约链的起点:它同时驱动运行时校验、REST/MCP 的 OpenAPI 文档,以及前端 Zod 代码生成。整个链路如下:

widget_specs/configs.py          Pydantic *WidgetConfig per type (+ shared common.py)
        │
        ├─► pydantic_openapi.py       injects `model_json_schema()` into OpenAPI components (no DRF bridge)
        ├─► openapi.py           polymorphic batch-add / PATCH / catalog OpenAPI (auto from WIDGET_SPECS)
        ├─► registry.py          WIDGET_SPECS manifest + validate_widget_config() (config, catalog labels, run_*)
        └─► widget_catalog.py    config_schema = model_json_schema() (for agents)

bin/build-dashboard-widget-types.py  (hogli build:widget-types — step 1)
        ├─► widget-date-from-options.json   date preset values + labels (from `constants.py`)
        └─► widget-form-fields.json         modal `.pick()` fields (from `WidgetSpec.form_fields`)

generate-widget-config-zod.mjs  (hogli build:widget-types — step 2)
        ├─► widget-config-property-keys.json   per-type keys/trees via `discoverCatalogEntryConfigPropertyKeys()`
        └─► Orval generateReusableSchemas (catalog slice → widget-config-schemas/*.zod.ts)

hogli build:openapi
        ├─► frontend/generated/api.schemas.ts
        ├─► products/dashboards/frontend/generated/widget-configs.zod.ts   (schemas, types, form picks)
        └─► services/mcp/...

后端各文件职责

文件 职责
configs.py 每种 widget 类型的 Pydantic 配置模型 —— 字段变更优先在这里改
common.py 共享的 dateRangewidgetFiltersfilterTestAccounts 等基础模型
registry.py WIDGET_SPECS 清单 + validate_widget_config() —— 每种类型的 manifest(Pydantic 模型、run_* 查询函数、scopes、RBAC、Agent 目录标签与可用性)
widgets/config.py 仅查询期使用 —— resolve_filter_test_accounts(config, team)(校验逻辑在 Pydantic 侧)
openapi.py 批量添加、目录 config_schema、dashboard PATCH 的多态 OpenAPI —— 完全由 WIDGET_SPECS 自动构建,无需按类型手写
api/widget_openapi_serializers.py dashboard.api 导入的稳定再导出层(实现位于 widget_specs/openapi.py

前端配置分层(禁止手工复制整份 schema)

文件 职责
generated/widget-config-schemas/*.zod.ts 每个组件一个的 Orval Zod(如 ErrorTrackingListWidgetConfig、共享的 WidgetDateRange 等)
generated/widget-configs.zod.ts 友好的再导出、推断类型、表单 .pick() schema(由 hogli build:widget-types 生成)
generated/widget-config-property-keys.json 每种类型顶层配置键清单,取自目录 OpenAPI 切片(由 generate-widget-config-zod.mjs 生成)
generated/widget-date-from-options.json 来自 constants.py 的日期预设 value + label 对(由 build-dashboard-widget-types.py 生成)
generated/widget-form-fields.json 每种 widget 的弹窗字段清单,取自 WidgetSpec.form_fields(由 build-dashboard-widget-types.py 生成)
widgets/widgetConfigValidation.ts 共享的 HogQL 过滤器辅助函数 + parseWidgetConfigApiError —— 不是按类型的 schema
widget_types/widgetConfigShared.ts 从生成的 JSON 再导出日期选择选项 + resolveWidgetFilterTestAccounts
widgets/*/*WidgetConfigValidation.ts 导入生成的表单 schema;仅做 API 错误解析(与校验逻辑同目录)
widget_types/catalog.ts 手写:标签、布局、经由生成 Zod 的 defaultConfig(预览见 widgets/previews/dashboardWidgetPreviews.ts

新增类型的默认值参考:widget-intake.md 的 Defaults 一节

源码视角:manifest 的真实形状

后端清单定义在 products/dashboards/backend/widget_specs/registry.pyWidgetSpec 是一个 frozen dataclass,包含 widget_typeconfig_model(Pydantic 模型类)、query_fn(懒加载的 run_* 函数)、required_scopesgroup_id/group_labellabel/description(Agent 目录文案)、required_product_access(RBAC)、availability_requirements(前置条件 flag)、form_fields(弹窗字段)、filter_fields(参与"widget 过滤器变更"埋点的字段)等:

@dataclass(frozen=True)
class WidgetSpec:
    widget_type: str
    config_model: type[BaseModel]
    query_fn: Callable[..., dict[str, Any]]
    required_scopes: tuple[str, ...]
    group_id: str
    group_label: str
    label: str
    description: str
    required_product_access: str | None
    product_access_denied_message: str | None
    availability_requirements: tuple[str, ...]
    form_fields: tuple[str, ...]
    filter_fields: tuple[str, ...]
    is_live: bool = False          # 实时 widget:一次性 SEED,客户端自刷新,禁止 dateRange/filterTestAccounts
    creation_flag: str | None = None  # 仅新增的灰度 gate

两个值得注意的实现细节:

  1. 实时 widget(live)的强约束__post_init__ 会检查 is_live=True 的类型是否在配置模型里引入了 dateRangefilterTestAccounts(见 _LIVE_FORBIDDEN_CONFIG_FIELDS),一旦出现就抛 ValueError —— 因为实时流无法应用测试账号过滤,窗口固定为实时。
  2. 校验是纯 Pydanticvalidate_widget_config() 先查 WIDGET_SPECS,未注册类型直接抛 DRF ValidationError;然后 config_model.model_validate(config),失败时把每个 locmsg 拼成一条人类可读的 config 错误;成功则 model_dump(mode="json", exclude_none=True) 归一化输出。

EXPECTED_WIDGET_TYPES 直接由 WIDGET_SPECS.keys() 派生(frozenset),因此"类型清单"永远和注册表一致,不需要手工维护第二份列表。

共享基础模型(common.py

products/dashboards/backend/widget_specs/common.py 定义了跨类型复用的模型:

  • WidgetDateRange:仅含 date_from,取值必须是预设相对区间(extra="forbid" 拒绝未知字段)。
  • WidgetFilterEntry:单个属性过滤项,包含 filterIdpropertyNameoptionIdoperator(取自 posthog.schema.PropertyOperator)、value(字符串/字符串数组/空),并要求列表值全为字符串。
  • WidgetListConfigBase:列表类 widget 的公共基类 —— filterTestAccounts(布尔)、widgetFiltersdict[str, WidgetFilterEntry],key 必须与 filterId 一致)。
  • 三个带边界的 limit 类型:WidgetLimit(1–25)、ActivityWidgetLimit(1–50)、LogsWidgetLimit(1–100),上限常量定义在 products/dashboards/backend/constants.py

日期预设的可选值(WIDGET_DATE_FROM_VALUES_ORDERED)同样在 constants.py-1M(1 分钟)、-30M-1h-3h-24h-7d-14d-30d-90d —— 注意注释里专门提醒 M 是分钟、m 才是月。这些常量同时是 widget-date-from-options.json 的输入,保证前后端选项完全一致。

每种类型的配置模型(configs.py

products/dashboards/backend/widget_specs/configs.py 按类型定义具体模型,目前包含 8 种类型(对应 DashboardWidgetType Literal):

widget_type 配置模型 关键字段(默认值)
activity_events_list ActivityEventsListWidgetConfig limit(默认 25)、eventNameproperties(最多 20 个过滤,含 key/label 长度与 value 长度约束)
error_tracking_list ErrorTrackingListWidgetConfig limit(默认 10)、orderByoccurrences)、orderDirectionDESC)、statusactive)、assignee
session_replay_list SessionReplayListWidgetConfig limitorderBystart_time)、savedFilterIdcollectionId(引用已保存过滤器/合集的 short_id
experiments_list ExperimentsListWidgetConfig limitorderBycreated_at)、statusall)、createdBy
experiment_results ExperimentResultsWidgetConfig experimentId(空直到用户在设置里选择)
survey_results SurveyResultsWidgetConfig surveyIddateRange(空 = 全部时间)、limit
logs_list LogsListWidgetConfig limit(默认 50)、orderBylatest)、severityLevelsserviceNameswrapLinestimezoneUTC/local)、savedViewId
conversations_recent_tickets ConversationsRecentTicketsWidgetConfig limitstatusall)、prioritieschannelassignees(支持 me/unassigned/{id,type})、search(≤200 字符)、savedViewId

模型都开启 extra="forbid",非法字段会被拒绝。枚举值(如 ErrorTrackingOrderByLogSeverityLevelWidgetOrderDirectionASC/DESC)都用 Literal 表达,因此会直接出现在 OpenAPI 的 enum 与 Zod 联合类型里,让 Agent 拿到的是"边界与选项",而非裸默认值。

Codegen 与 CI:一条命令串起全部生成

没有独立的 widget codegen 步骤 —— 全部由一条命令完成:

hogli build:openapi   # openapi-schema → build:widget-types → openapi-types → MCP

Widget 配置的 Zod 是产品级作用域的:products/dashboards/frontend/bin/generate-widget-config-zod.mjsfilterSchemaByOperationIds 从目录 OpenAPI 操作(dashboards_widget_catalog_retrieveincludeResponseSchemas: true)里切出 catalog 片段,再调用 tools/openapi-codegen 中的 Orval(要求 8.14+)并开启 generateReusableSchemas: true,产物落到 generated/widget-config-schemas/,再在 widget-configs.zod.ts 里聚合友好导出 —— 这与 frontend/bin/generate-openapi-types.mjs(全量 API 类型生成)是两条独立流水线。

关键约束:OpenAPI 必须暴露一个非空DashboardWidgetConfig oneOf(由 pydantic_openapi.py 注入),否则 Orval 会生成一个空的 TS union 类型,前端直接失去类型保障。

Pydantic → OpenAPI 的注入原理

pydantic_openapi.py 是整个桥接的"无 DRF 中间层"实现:

  • pydantic_model_to_openapi_components() 调用 model.model_json_schema(mode="serialization"),把 Pydantic 的 $defs 提升为具名 OpenAPI 组件,并把 #/$defs/ 引用重写为 #/components/schemas/
  • pydantic_stub_serializer() 生成一个空的 serializer 外壳(schema 内容由后处理注入,DRF 本身不参与);
  • pydantic_config_field() 返回一个 OpenAPI 形状为 $refJSONField
  • inject_widget_spec_pydantic_components() 是 drf-spectacular 的 POSTPROCESSING_HOOKS 入口:遍历 WIDGET_SPECS 注入每个 config_model 的组件,并用所有配置模型的 $ref 组装 DashboardWidgetConfig = {"oneOf": [...]}。若注入时发现同名组件已存在且内容不同(如 PropertyOperator/query Pydantic 路径撞名),会通过 spectacular_warn 告警 —— 该告警计入 GENERATOR_STATS,在 --fail-on-warn 下会直接打断构建,提示你重命名模型。

多态序列化层在 openapi.py_build_openapi_serializers() 为每个类型动态构造三类 serializer —— 配置序列化器(*OpenApiSerializer)、批量添加请求({prefix}AddRequestOpenApiSerializer,含单值 widget_type ChoiceField + config)、目录条目({prefix}CatalogEntryOpenApiSerializer,含 config_schemalive 标志),再组合成 AddDashboardWidgetRequestOpenApiUpdateDashboardWidgetRequestOpenApiWidgetCatalogEntryOpenApiPolymorphicProxySerializerPatchedDashboardOpenApiSerializer 则定义了 dashboard PATCH 的 OpenAPI-only body(含嵌套的 tiles[].widget.config)。这些全部由 WIDGET_SPECS 自动生成,没有任何按类型的手写接线

本地开发与 CI 流程

本地开发:Vite 读取的是 products/dashboards/frontend/generated/已提交的文件 —— hogli up 或保存时不会自动重新生成。改动 widget_specs/ 或序列化器之后,需要手动跑 hogli build:openapi提交生成差异。没有 pre-commit hook 兜底。

CI(ci-backend.yml 中的 check-openapi-types:执行同样的 hogli build:openapi,然后 diff 生成产物。同仓库 PR 可能自动提交漂移;fork PR 和未推送的修复会以"run hogli build:openapi locally"失败。触发器覆盖 products/**/backend/**(含 widget_specs/)和 products/*/frontend/generated/**

新增 widget_type:在 widget_specs/configs.py 中按 *ListWidgetConfig*WidgetConfig 的命名约定新增 Pydantic 模型即可 —— build:widget-types 会自动推导 Orval 导出名,如果 OpenAPI 切片里缺了该模型就会失败。

Schema 生成阻塞点:枚举名冲突

build:openapi-schema 启用了 --fail-on-warn。多态按类型序列化器各自使用单例 ChoiceField 表示 widget_type,而 dashboard.py 使用完整的 EXPECTED_WIDGET_TYPES 列表 —— 这会让 drf-spectacular 的枚举名发生碰撞。发新类型时必须给 posthog/settings/web.py 中的 ENUM_NAME_OVERRIDES 加上 {YourWidgetTypeEnum: ["your_widget_type"]}(该配置位于 posthog/settings/web.py 第 554 行附近,注释里同样指引用 find_enum_collisions 诊断)。

双保险验证:hogli build:widget-typestest_widget_openapi_enums.py 会在注册表类型缺少 override 时失败;spectacular 碰撞测试在 override 哈希错误时失败。诊断命令:

python manage.py find_enum_collisions   # 逻辑在 posthog/openapi/enum_collisions.py

Schema 一致性测试(便宜的漂移守卫)

改动 widget_specs/ 时至少跑这两条:

hogli test products/dashboards/backend/api/test/test_widget_config_schema_parity.py
hogli test products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts

后端侧校验目录 config_schema 与 Pydantic JSON schema 一致;前端侧校验 Zod 配置顶层键与后端属性映射(widget-config-property-keys.json)一致。更多 CI 类型安全网(注册表 ↔ catalog ↔ 序列化器数量、dashboard PATCH OpenAPI ⊆ 运行时可写字段、前端 DASHBOARD_WIDGET_REGISTRY satisfies Record<…> 等)见 architecture.md 的 CI 一节

Footguns:配置与代码生成的常见陷阱

常见错误 修复方法
加 widget 字段时瘦身 PatchedDashboardOpenApiSerializer extend_schema(request=...)整体替换 PATCH schema —— 应当扩展类,绝不重写。CI:test_dashboard_openapi.py 会把运行时 DashboardSerializer 可写字段(扣除 api/test/dashboard_openapi_test_helpers.py 中的排除项)与 serializer + spectacular 输出对比;MCP 测试把 dashboard-update schema 链到 DashboardsPartialUpdateBody
在 dashboard PATCH 上放嵌套的按类型 widget 配置序列化器 运行时序列化器上保持 tile configJSONField —— 类型化 OpenAPI 只存在于 widget_specs/openapi.pyPatchedDashboardOpenApiSerializer
手写前端 Zod 配置 schema 统一走 codegen 生成 widget-configs.zod.ts;在 registry.pyWidgetSpec 上加 Pydantic *WidgetConfig + form_fields
在 widget 配置里导入共享的 posthog.schema 模型 优先在 configs.py 用本地 Pydantic 模型(如 WidgetAssigneeFilter)—— 避免 spectacular 组件名冲突
手工重复 catalog config_schema 后端目录用 config_model.model_json_schema() —— Agent 拿到的应是边界/选项/描述,而不只是默认值
改生成的 Zod/TS 但不重新生成 hogli build:openapi,提交 products/dashboards/frontend/generated/* —— CI check-openapi-types 会 diff,失败或自动提交
hogli build:openapi-schema 因警告失败 --fail-on-warn 所致 —— 用 find_enum_collisions + ENUM_NAME_OVERRIDES 修复,详见上文 Codegen 与 CI 一节

推荐的新类型落地路径

  1. configs.py 定义 YourWidgetConfig(继承 WidgetListConfigBaseWidgetDateRangeConfigBaseextra="forbid",用带边界的 Annotated limit);
  2. registry.pyWIDGET_SPECS 注册 WidgetSpec(含 query_fn 懒导入、scopes、RBAC、catalog 文案、form_fields/filter_fields);
  3. posthog/settings/web.pyENUM_NAME_OVERRIDES 补上枚举映射;
  4. hogli build:openapi 生成 OpenAPI + widget-configs.zod.ts + MCP schema,提交全部生成产物;
  5. 跑两条 schema parity 测试确认无漂移;
  6. 前端在 widgets/registry.tsx 注册 Component/EditModal,并在 widget_types/catalog.ts 补目录条目(布局、默认值、预览见 architecture.md 的 reference implementation)。

全程遵循"后端 Pydantic 是唯一事实源、codegen 是唯一写入路径、CI 是最后一道防线"三条铁律,widget 配置体系就能始终在运行时校验、REST/MCP OpenAPI 与前端 Zod 之间保持严格一致。

热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.15 K
2.77 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
929
1.85 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.94 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
534
603
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23