关于 querying-canvas-data
这份指南介绍如何通过宿主提供的 `ph` 桥接接口查询并呈现 PostHog 数据。内容涵盖查询选择、结果验证与处理、渐进式加载及画布状态,面向开发数据驱动画布的开发者。
主要功能
- 01加载已保存的洞察或运行类型化查询
- 02将 HogQL 作为有说明的最后选择
- 03让展示的指标可供验证
- 04明确处理查询结果与错误
- 05逐步加载各个内容区块
- 06保存用户专属或共享画布状态
适用场景
- 01构建带有 PostHog 洞察链接的指标画布
- 02创建各数据区块独立加载的状态看板
- 03从系统表展示实时任务数据
- 04持久保存画布草稿或团队共享设置
MCP 相关
建议先使用 PostHog MCP 洞察工具创建并保存洞察,再在画布中加载。画布本身通过 `ph` 桥接接口访问 PostHog。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ ai-plugin --skill querying-canvas-data安装后在 Claude Code 中输入/querying-canvas-data确认已加载。
附带文件references/canvas-sdk.d.ts
详细介绍
查询画布数据
ph 桥接是画布与 PostHog 通信的唯一方式。通过
import { ph } from "@posthog/canvas-sdk" 导入——这是平台提供的模块,因此无需添加
dependencies 条目。同一个对象也会作为 window.ph 全局变量安装,现有画布会使用它;新代码优先使用导入方式。其类型化接口见
references/canvas-sdk.d.ts。切勿初始化它:凭据保留在宿主中,而 fetch()、posthog-js 和手动编写的客户端都无法从沙盒访问 PostHog。
外部请求和资源必须使用在 capabilities.network.origins 中声明的非 PostHog 源,且只能在已发布的画布中使用——编辑模式预览会阻止所有直接网络访问。
这也包括外部样式表;远程脚本仍会被阻止。
数据层级——每项指标都应由已保存的洞察支撑
- 首选——保存洞察,并通过引用加载。 使用 PostHog MCP 洞察工具创建/保存一个通过洞察查询类型计算指标的洞察(TrendsQuery、FunnelsQuery、RetentionQuery、PathsQuery 或 WebOverviewQuery/WebStatsTableQuery 等 Web 分析类型——而非原始 SQL)。确认其数值,记下
short_id,然后使用await ph.loadInsight(shortId, { dateRange })呈现。这些查询已验证——数值与 PostHog UI 完全一致(会话划分、独立用户、细分项、跳出率)。切勿虚构查询或猜测事件/属性名称;请先通过 MCP 查明并保存。 - 次选——临时类型化节点:当确实不适合保存洞察时,使用
ph.query({ kind: "TrendsQuery", series: [...], dateRange: {...} })。 - 最后手段——内联 HogQL:仅当没有任何洞察类型能表达该指标时,才使用
ph.query("SELECT …");此时 SQL 和日期范围都由你负责。
特别是对于 Web 分析看板,请使用 Web 分析查询类型——原始 HogQL 对跳出率、会话划分、渠道归因和独立访客计数的计算可能不准确。
无论使用哪个层级,发布前都要在项目的 capabilities 中声明:将每个 ph.loadInsight 短 ID 添加到 capabilities.posthog.insights,将每个 ph.capture 事件名称添加到 captureEvents,并为任何 ph.query 调用设置 inlineQueries: true。宿主会在运行时拒绝未声明的调用,验证也会因未声明的字面值而失败。
可验证性 — 每项声明都必须能在 PostHog 中核实
无法核实的数字,就无法让人信任。画布展示的每个有数据支撑的数值——KPI、图表、表格或结论——都必须根据所属层级提供相应的验证方式:
- 由洞察支持的指标应链接到 PostHog 中的真实洞察。 对于从已保存洞察加载的指标(首选层级),应渲染一个“在 PostHog 中查看”入口,并在点击时调用
ph.openExternal(insightUrl)。在编写时使用generate-app-urlMCP 工具生成 URL(路径模板为/insights/{id},其中{id}为洞察的短 ID),并将返回的 URL 原样写入源代码——绝不要手动拼接。ph.openExternal只会打开https://*.posthog.comURL,且只能从用户操作中调用,因此应将其绑定到按钮或链接,绝不要在加载或渲染时调用。也不要将洞察已保存的查询文本写入源代码:所有画布查看者都能读取画布源代码,而 PostHog 会强制执行对洞察的访问权限——查看者可通过链接查看查询,并应用自己的权限。 - 即席查询应公开实际运行的确切查询,并可在当前页面查看。 对于由
ph.query(类型化节点或内嵌 HogQL)计算出的数值,应通过模态框或附加在卡片上的可折叠说明展示其背后的查询——HogQL 文本,或以 JSON 美化输出的类型化查询节点。在 React 画布中可使用 Quill 的Dialog或Collapsible,在 HTML 画布中可使用<details>元素。请使用传给ph.query的同一个常量或构造器来渲染查询,确保显示的查询始终与执行的查询一致。这不会披露查看者尚未运行过的内容:ph.query会以已登录查看者的身份执行。
这些并非可有可无的润色:不提供这些验证方式的画布,若展示了 PostHog 数据,就不算完整。验证入口应保持简洁——每张由洞察支持的卡片放置一个小型链接图标;每张即席查询卡片提供一个“查看查询”说明;或者使用一个共享模态框,列出画布运行的所有即席查询,并标明每个查询支持的数值。
对于状态看板,将 refresh 设为缓存生命周期(秒)。使用 30 到 86400 之间的整数(一天);超出此范围或使用小数都会在运行时失败:
await ph.loadInsight(shortId, { refresh: 30 })
await ph.query(queryNode, {}, { refresh: 30 })
结果形状 — 正确读取,否则所有数值都会显示为 0
- Trends 风格的结果(洞察查询类型,通过
ph.loadInsight或类型化节点获取):results是一个由序列对象组成的数组,而不是行数组。每个序列包含data: number[](各时间间隔的值)、days: string[](ISO 日期)、labels: string[]、count(合计值)、aggregated_value(单一数值的总计)、label,以及可选的compare_label: "current" | "previous"。KPI 总计应读取results[0].count(或.aggregated_value);折线图应绘制results[0].data随results[0].days变化的趋势。count会累加各时间间隔的值;对于采用math: "dau"的独立用户序列,若某人有多天活跃,就会被重复计数——若要得到某个时期内去重用户的 KPI,应在查询中设置trendsFilter: { display: "BoldNumber" },并读取aggregated_value。有对比时,应通过compare_label === "previous"查找上一期的序列——绝不要按索引查找。此处的columns为空。 - SQL 结果:
{ columns: string[], results: rows[][] }——每一行都是一个单元格值数组,顺序与columns一致。
渐进式加载 — 每个分区的数据到达后立即渲染
PostHog 查询每个可能需要几秒,而一个看板通常会运行多个查询。切勿等所有查询都完成后才渲染:
- 挂载时并发发起互不依赖的查询;切勿用连续的
await串联无关查询。宿主每次运行 8 个数据请求,其余请求进入最多 32 个请求的队列等待,因此即使看板的分区数多于并发数,也仍会逐个加载。宽到超出队列容量的看板会收到拒绝的额外请求,错误消息中会说明原因;运行时会在放弃之前退避一段时间后重新发送每个请求。对于这么宽的看板,应合并查询(一个查询返回所有行,在客户端进行切分),但每个分区仍使用独立状态。 - 为每个查询设置自己的
{ loading, error, data }状态,让每张卡片、图表或表格在自己的结果到达后立即将骨架屏替换为数据。一个共享的loading标志或针对多个独立查询的单个Promise.all,都会让最快的指标也要等待最慢的指标 — 画布必须逐步填充,而不是一次性全部显示。 - 立即渲染静态外框(标题、日期选择器,以及内部带有骨架屏的卡片框);只有每个分区内的值需要等待查询。
- 延后不需要在首次绘制时加载的查询:标签页后方、折叠分区或下钻视图中的内容,应在用户展开时才运行查询,而不是在挂载时运行。
使用 useEffect 和 useState 加载数据,并在查询中进行聚合;切勿获取原始事件数据转储。将查询被拒绝和结果为空视为不同状态:.catch 必须设置错误状态并在界面中显示(消息 + 重试),切勿将错误处理成零值、空图表或“无数据”消息 — 吞掉错误会让实际故障(表缺失、身份验证失败)看起来像是数据缺失。只有查询成功但没有返回行时,才显示空状态。
日期范围
- 将画布日期选择器的时间范围直接传入
dateRange:ph.loadInsight(shortId, { dateRange: { date_from: win.start.toISOString(), date_to: win.end.toISOString() } })— 已保存的 insight 会按该时间范围重新限定,无需使用时间 SQL。类型化节点也使用相同的dateRange。时间范围改变时,重新运行每个查询。 - 已保存的 SQL insight 可能会忽略
dateRange(其时间范围在 SQL 内部)— 因此应优先选择 insight 查询类型。如果其时间范围来自{variables.…}占位符,则应通过variables(见下文)设置;dateRange永远不会传递给它。 - 仅将内联 HogQL 作为应急方案:切勿写入
now()或硬编码的 INTERVAL。计算 Unix 时间边界(Math.floor(win.start.getTime() / 1000)),并使用半开区间timestamp >= toDateTime(fromUnix) AND timestamp < toDateTime(toUnix)。上一时段 = 紧接当前时段之前、时长相同的时间范围;使用toStartOfDay/toStartOfHour分桶。
SQL 变量
HogQL 包含 {variables.name} 占位符的已保存 SQL insight,可在每次调用时传入变量值,键名使用变量的代码名称(而不是其 uuid):
await ph.loadInsight(shortId, { variables: { product: 'surveys', month: '2026-07-01' } })
这样,一个已保存的 insight 就能填充整个看板 — 每个产品加载一次相同的产品专属 insight — 而无需让每个图块都使用该 insight 已保存的默认值。
- 先从 insight 的查询中读取代码名称(通过 MCP 使用
insight-get)。宿主会拒绝 insight 未使用的变量,也会拒绝未生效的变量值,而不是静默回退到已保存的值 — 因此变量不匹配会显示为可见错误,而不会显示另一个产品的数据。 - 变量属于读取缓存键的一部分,因此 N 个产品意味着 N 次加载。优先使用能以行的形式返回所有产品的一个 insight,而不是加载 N 次同一个 insight,然后在客户端进行切分。
- 变量值的类型由 PostHog 中的变量定义决定(String / Number / Boolean / Date / List);传入与 insight 预期相同的形状,多选 List 变量则传入数组。
实时任务数据
对于任务收件箱、队列或状态看板,请通过 ph.query 查询 system.tasks 和 system.task_runs。编写内容时不要调用 posthog:tasks-list 并嵌入其响应:这样得到的是快照,而系统表可让渲染后的画布保持实时。
这些表以已登录的查看者身份运行。它们的范围限定为项目,且需要具有 Tasks 资源的访问权限。system.tasks 仅包含在公开空间中提交的非内部任务;不包含私有、个人、未归档和内部任务。始终明确排除软删除的任务。
如果画布需要显示当前状态,请将任务与其最近一次运行关联:
const data = await ph.query(`
SELECT
t.id,
t.task_number,
t.title,
t.repository,
t.created_by_id,
t.created_at,
t.updated_at,
latest.status AS latest_run_status
FROM system.tasks AS t
LEFT JOIN (
SELECT
task_id,
argMax(status, tuple(created_at, id)) AS status
FROM system.task_runs
GROUP BY task_id
) AS latest ON latest.task_id = t.id
WHERE t.deleted = 0
ORDER BY t.updated_at DESC
LIMIT 100
`)
这是内联 HogQL,因此请声明 capabilities.posthog.inlineQueries: true。使用 ph.navigate.toTask(id) 渲染链接,不要自行拼接任务 URL。
不要承诺这些表无法实现的筛选条件。channel_id 无法查询,因此画布目前无法将此查询限制为仅显示其所在空间的任务。筛选当前查看者的任务也需要已知的数字用户 ID;画布运行时不会注入该 ID。如果请求依赖于“此空间”或“我的任务”,请说明这些限制,而不是默默显示整个项目的公开任务。
运行时记忆——ph.state
每个画布的持久键值存储。请在 capabilities.posthog.state 中声明所用的每个作用域(["user"]、["shared"] 或两者);未声明的作用域无法通过验证,宿主会在运行时拒绝它们。作用域 "user"(未传入作用域时的默认值)对每位查看者都是私有的;"shared" 是每个画布一个值,整个团队都可见。
const draft = await ph.state.get('draft') // user scope by default; null when unset
await ph.state.set('draft', { text }) // JSON value, capped at 64 KB serialized
await ph.state.set('draft', null) // null deletes the key
await ph.state.set('board', { columns }, { scope: 'shared' }) // team-visible
const entries = await ph.state.list({ scope: 'shared' }) // [{ scope, key, value, updatedAt }]
- 在挂载时通过 effect 加载状态,并在加载完成前渲染骨架屏;写入采用最后写入者胜出,因此应重新读取(或信任自己的写入),而不是进行合并。
- 每个作用域最多 256 个键。将大型数据存储在 PostHog(insights、数据仓库)中,并在此处引用。
- 状态是团队可见的应用数据——绝不能存储秘密或查看者的个人身份信息。
当用户询问画布当前的进度或设置时,不要仅凭源代码推断。读取画布源代码后,使用画布 ID 调用 canvas-state-retrieve。它会返回共享状态,以及已通过身份验证的用户在公开频道或其个人频道中画布的用户作用域状态。当用户要求更改这些值时,使用 canvas-state-set;先读取状态,保留无关键,并使用画布源代码预期的作用域。
画布讨论使用通用评论工具。使用 comments-list 并按 scope=canvas 和画布 ID 作为 item_id 进行筛选,以读取评论。使用相同的作用域和条目 ID,通过 comments-create 创建顶层评论或回复。讨论串属于画布,因此任务 ID 是可选的。仅当任务生成或发布了画布时,才在 item_context.taskId 中填写该任务 ID;API 会拒绝任何其他任务。画布所在的空间决定访问权限:能够查看该空间的用户可以读取和撰写其中的评论。
PostHog 写入 — ph.actions
ph.actions.invoke(verb, payload) 会以查看者的身份向 PostHog 写入数据。请在
capabilities.posthog.actions 中声明每个动词;未声明或未注册的动词会导致验证失败,
宿主也会在运行时拒绝它们。调用必须绑定到明确的用户操作(查看者点击的按钮)——宿主会拒绝在加载或渲染时发起的调用。
请直观地呈现结果或抛出的错误,并在调用进行期间禁用按钮——每次调用都会真正写入 PostHog。
动词注册表是动词的事实来源。绑定动词之前,请使用
canvases-actions-retrieve 工具列出动词:每个条目都包含 verb、summary、destructive 和
usage——即载荷和结果的结构、调用的实际作用,以及应显示的确认文案。请严格遵循动词的
usage,包括成功消息可以作出的声明。不要根据对应产品自身的 MCP 工具或技能来推断动词的载荷——调用 MCP 工具(由你现在使用自己的凭据调用)与调用画布动词(由查看者稍后在已发布的画布中调用)在载荷结构、身份验证和行为上都不相同。调用方式如下:
const { result } = await ph.actions.invoke('tasks.create', { title, description })
PostHog MCP 工具 — ph.tools
ph.tools 会以查看者的身份运行 PostHog MCP 工具,并使用查看者自己的权限。该工具在
PostHog MCP 服务上运行,因此其输入与你亲自调用该工具时传入的输入相同。当看板需要 PostHog MCP 工具已支持的控件时使用它,例如发布功能标志。
- 请在
capabilities.posthog.tools中声明每个工具。画布只能使用允许列表中的工具。canvases-actions-retrieve工具的tools列表会列出这些工具,并标明每个工具的read_only和destructive属性。使用info <tool>查看工具的输入架构。 ph.tools.read(tool, args)只接受只读工具。可在加载或渲染时运行。ph.tools.call(tool, args)会更改数据。只能通过点击操作调用。宿主会向查看者显示工具及其参数,并要求确认;同时会拒绝在加载或渲染时发起的调用。- 两者都会返回工具的 JSON 结果;失败时会抛出包含工具错误消息的异常。 请呈现该消息,并在调用进行期间禁用按钮。
写入功能标志需要使用该标志当前的 version。读取标志,使用该版本写入,并保留写入返回的版本。发生版本冲突时,请在下一次写入前重新读取该标志:
const flag = await ph.tools.read('feature-flag-get-definition', { id: flagId })
const updated = await ph.tools.call('feature-flag-set-release-condition-rollout', {
id: flagId,
condition_index: 0,
rollout_percentage: 25,
version: flag.version,
})
实时第三方数据 — ph.connectors
ph.connectors.call(provider, tool, args, { refresh? }) 使用查看者自己的连接,在查看时读取第三方服务的数据。适用于必须因人而异并保持最新的数据:未合并的拉取请求、今天的会议、分配的议题。编写内容时,切勿自行获取这类数据并将结果写入源文件——快照一经发布就会过时,而且所有查看者看到的都会是作者的数据。
- Provider 为
github(原生,通过查看者的个人 GitHub 连接)或mcp:<server host>(查看者在 MCP 商店中已连接的任意服务,例如mcp:mcp.calendly.com)。使用canvas-connectors-retrieve工具发现 provider、工具、参数架构和每个工具的usage;传入mcp_hosts可检查当前用户尚未连接的服务。只能调用目录条目中is_read_only: true的工具。 MCP 工具需要明确的只读提示,且名称必须通过本地读取动词检查。 - 在
capabilities.connectors中声明每个 provider 和工具,格式为[{ "provider": "github", "tools": ["list_pull_requests"] }]。验证会拒绝未声明的ph.connectors.call字面量,宿主也会在运行时拒绝未声明的调用。 - 调用会解析为
{ status, result, detail, truncated, connect_path }。根据status分支处理:ok—result包含工具输出。原生工具返回其文档说明的结构;MCP 工具返回{ content, structured_content, is_error }(MCP 内容块)。not_connected/needs_reauth— 此查看者没有可用的连接。显示“连接 GitHub”(或服务名称)按钮,并在点击时调用ph.connectors.connect(provider);宿主会打开相应的设置页面。切勿将此情况视为空数据。blocked、write_blocked、tool_missing、upstream_error— 显示detail,并提供重试选项。
truncated: true表示结果超过 256 KB,已截断为预览;应缩小调用范围(使用更小的limit,或只查询一个代码库),而不是在客户端分页。- 调用可以在挂载时启动,但宿主会先请求查看者授权,然后才读取连接器数据。授权仅适用于一个画布版本、一个 provider 和一个工具。拒绝授权会导致调用失败;请显示错误并提供重试按钮。
- 结果会按画布版本缓存
refresh秒(默认值为 60,范围为 30–86400)。账户、组织和项目发生变化时,结果和授权都会清除。 - 将连接器结果保存在组件状态或作用域为
"user"的ph.state中。 含有连接器的画布不能声明共享状态。验证和 API 都会拒绝这种组合。
const [prs, setPrs] = useState<{ loading: boolean; status?: string; rows?: PullRequest[] }>({ loading: true })
useEffect(() => {
ph.connectors
.call('github', 'list_pull_requests', { repository: 'example/app', state: 'open' }, { refresh: 60 })
.then((res) => setPrs({ loading: false, status: res.status, rows: res.result?.pull_requests ?? [] }))
.catch((error) => setPrs({ loading: false, status: 'error', rows: [] }))
}, [])
副作用
ph.capture(event, properties?, distinctId?)— 记录交互的分析事件 (触发后不等待结果)。会话回放、$session_id和用户归因均由宿主自动处理;切勿初始化录制、设置会话 ID 或自行实现捕获逻辑。ph.openExternal(url)— 打开 PostHog HTTPS URL,以及用户点击的https://github.com/<owner>/<repo>/pull/<number>链接。GitHub PR 链接无需确认对话框即可打开。文件、提交、检查和片段链接均允许;凭据、自定义端口、查询字符串和其他域名均不允许。 沙盒中的target="_blank"导航会被阻止,因此请使用桥接接口,而不要使用浏览器回退方案。ph.navigate.toTask(id)/.toNewTask()/.toCanvas(id)/.toNewCanvas()— 在应用内 导航至画布自身频道中的页面。ph.navigate.toNewTask({ prompt, repository })— 用户点击后,在新标签页中打开预填充的任务表单。prompt最多为 16,000 个字符;repository的格式为owner/repo。两者均为可选项。 设置 repository 会为此任务选择云模式,但不会更改空间。 查看者可以审核并发送提示词;打开表单不会启动运行。检查较早发布的制品是否包含ph.navigate,如果部署后缺少此项,请要求重新构建。