PostHog Dashboard Widget 配置契约与代码生成:从 Pydantic 单一事实源到前端 Zod 的全链路指南
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 |
共享的 dateRange、widgetFilters、filterTestAccounts 等基础模型 |
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.py。WidgetSpec 是一个 frozen dataclass,包含 widget_type、config_model(Pydantic 模型类)、query_fn(懒加载的 run_* 函数)、required_scopes、group_id/group_label、label/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
两个值得注意的实现细节:
- 实时 widget(live)的强约束:
__post_init__会检查is_live=True的类型是否在配置模型里引入了dateRange或filterTestAccounts(见_LIVE_FORBIDDEN_CONFIG_FIELDS),一旦出现就抛ValueError—— 因为实时流无法应用测试账号过滤,窗口固定为实时。 - 校验是纯 Pydantic:
validate_widget_config()先查WIDGET_SPECS,未注册类型直接抛 DRFValidationError;然后config_model.model_validate(config),失败时把每个loc与msg拼成一条人类可读的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:单个属性过滤项,包含filterId、propertyName、optionId、operator(取自posthog.schema.PropertyOperator)、value(字符串/字符串数组/空),并要求列表值全为字符串。WidgetListConfigBase:列表类 widget 的公共基类 ——filterTestAccounts(布尔)、widgetFilters(dict[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)、eventName、properties(最多 20 个过滤,含 key/label 长度与 value 长度约束) |
error_tracking_list |
ErrorTrackingListWidgetConfig |
limit(默认 10)、orderBy(occurrences)、orderDirection(DESC)、status(active)、assignee |
session_replay_list |
SessionReplayListWidgetConfig |
limit、orderBy(start_time)、savedFilterId、collectionId(引用已保存过滤器/合集的 short_id) |
experiments_list |
ExperimentsListWidgetConfig |
limit、orderBy(created_at)、status(all)、createdBy |
experiment_results |
ExperimentResultsWidgetConfig |
experimentId(空直到用户在设置里选择) |
survey_results |
SurveyResultsWidgetConfig |
surveyId、dateRange(空 = 全部时间)、limit |
logs_list |
LogsListWidgetConfig |
limit(默认 50)、orderBy(latest)、severityLevels、serviceNames、wrapLines、timezone(UTC/local)、savedViewId |
conversations_recent_tickets |
ConversationsRecentTicketsWidgetConfig |
limit、status(all)、priorities、channel、assignees(支持 me/unassigned/{id,type})、search(≤200 字符)、savedViewId |
模型都开启 extra="forbid",非法字段会被拒绝。枚举值(如 ErrorTrackingOrderBy、LogSeverityLevel、WidgetOrderDirection 的 ASC/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.mjs 用 filterSchemaByOperationIds 从目录 OpenAPI 操作(dashboards_widget_catalog_retrieve,includeResponseSchemas: 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 形状为$ref的JSONField;inject_widget_spec_pydantic_components()是 drf-spectacular 的POSTPROCESSING_HOOKS入口:遍历WIDGET_SPECS注入每个config_model的组件,并用所有配置模型的$ref组装DashboardWidgetConfig = {"oneOf": [...]}。若注入时发现同名组件已存在且内容不同(如PropertyOperator与/queryPydantic 路径撞名),会通过spectacular_warn告警 —— 该告警计入GENERATOR_STATS,在--fail-on-warn下会直接打断构建,提示你重命名模型。
多态序列化层在 openapi.py:_build_openapi_serializers() 为每个类型动态构造三类 serializer —— 配置序列化器(*OpenApiSerializer)、批量添加请求({prefix}AddRequestOpenApiSerializer,含单值 widget_type ChoiceField + config)、目录条目({prefix}CatalogEntryOpenApiSerializer,含 config_schema 与 live 标志),再组合成 AddDashboardWidgetRequestOpenApi、UpdateDashboardWidgetRequestOpenApi、WidgetCatalogEntryOpenApi 等 PolymorphicProxySerializer。PatchedDashboardOpenApiSerializer 则定义了 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-types 和 test_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 config 为 JSONField —— 类型化 OpenAPI 只存在于 widget_specs/openapi.py(PatchedDashboardOpenApiSerializer) |
| 手写前端 Zod 配置 schema | 统一走 codegen 生成 widget-configs.zod.ts;在 registry.py 的 WidgetSpec 上加 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 一节 |
推荐的新类型落地路径
- 在 configs.py 定义
YourWidgetConfig(继承WidgetListConfigBase或WidgetDateRangeConfigBase,extra="forbid",用带边界的 Annotated limit); - 在 registry.py 的
WIDGET_SPECS注册WidgetSpec(含query_fn懒导入、scopes、RBAC、catalog 文案、form_fields/filter_fields); - 在
posthog/settings/web.py的ENUM_NAME_OVERRIDES补上枚举映射; - 跑
hogli build:openapi生成 OpenAPI +widget-configs.zod.ts+ MCP schema,提交全部生成产物; - 跑两条 schema parity 测试确认无漂移;
- 前端在
widgets/registry.tsx注册Component/EditModal,并在widget_types/catalog.ts补目录条目(布局、默认值、预览见 architecture.md 的 reference implementation)。
全程遵循"后端 Pydantic 是唯一事实源、codegen 是唯一写入路径、CI 是最后一道防线"三条铁律,widget 配置体系就能始终在运行时校验、REST/MCP OpenAPI 与前端 Zod 之间保持严格一致。
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 StartedRust4.21 K635- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python70
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java161
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java90
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript120
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python300