关于 experiment-audit
experiment-audit 是面向 PostHog 的技能,用于检查实验是否采集变体曝光数据、归因是否贯穿转化漏斗,以及指标是否符合预期。它要求先验证客户项目范围,并生成适合发布到 Slack 的审计结果;无法可靠检查时会明确拒绝并记录原因。
主要功能
- 01审计前验证客户项目范围
- 02检查实验及关联功能标记设置
- 03核验曝光事件与漏斗归因
- 04检查指标、变体、定向和发布比例
- 05无法可靠审计时明确拒绝
- 06脱敏并记录拒绝原因和 MCP 错误
适用场景
- 01解读结果前验证实验配置
- 02发现变体名称或流量分配不匹配
- 03排查功能标记定向问题
- 04记录审计失败以供后续调试
MCP 相关
使用活跃的 PostHog MCP 确认当前项目,并获取实验、功能标记配置和曝光事件。若无法确认客户项目或 MCP 访问失败,审计必须停止。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ skills --skill experiment-audit安装后在 Claude Code 中输入/experiment-audit确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonassets/settings.local.jsonscripts/impersonate-audit-shim.shimpersonate-audit.shredact-test.shredact.sh详细介绍
实验审计
验证客户的实验是否确实在收集变体数据、下游归因是否贯穿整个漏斗,以及指标衡量的是否是客户认为它们衡量的内容。输出一份可直接发送到 Slack 的报告,按客户几乎总会提出的四个问题分组。
步骤 0 — 运行前确认范围
本技能假定当前的 PostHog MCP 已设为客户的项目,而不是你自己的项目。务必先问:
“我现在在哪个项目中?列出最近的 5 个实验。”
解读答案:
- 客户的项目。 继续步骤 1。
- 你自己的内部项目(通常是 “PostHog App + Website”,id 2)。模拟会话未能正确路由。按照拒绝协议拒绝。不要继续。
- MCP 调用彻底失败(无响应、身份验证错误、工具不可用)。按照拒绝协议拒绝。不要继续。
拒绝协议
如果任何结构性条件导致你无法实际审计客户的项目,请明确拒绝并停止。不要退而求其次,采用以下反模式:
- 生成提示词、清单或操作说明,让用户粘贴到 PostHog AI、另一个 Claude 会话或客户自己的工具中。这并没有提供帮助;它只是悄悄把工作转嫁给用户,同时掩盖了此技能无法执行的原因。
- 在没有真实数据的情况下,提供“审计报告示例”模板内容。
- 根据对客户或产品的既有了解猜测审计结果。
何时拒绝(以下每种情况都会触发此协议)
reason_code | 条件 |
|---|---|
wrong_project | what project am I in? 返回的项目 ID 不是客户的项目(通常是你自己的 “PostHog App + Website”,id 2)。只要项目身份不正确,就使用此代码,即使该项目恰好没有任何内容。 |
empty_project | MCP 所在项目是客户的项目(身份匹配),但 experiment-list 返回零条结果。审计没有可用数据。与 wrong_project 的区别:项目身份符合预期。 |
mcp_disconnected | 没有任何 MCP 服务响应——工具调用在到达服务之前就失败了(传输错误、“MCP not connected”、/mcp 中没有 posthog 条目)。 |
mcp_auth_expired | MCP 服务有响应,但本应成功的调用返回 401/403(模拟会话已失效,或令牌在审计过程中被撤销)。 |
switch_project_failed | switch-project 返回错误,或你尝试切换到的项目不在当前用户可访问的项目中。 |
experiment_not_found | experiment-list 搜索未找到用户指定的实验。不要用名称相似的实验代替——应拒绝并询问用户。 |
tool_call_error | MCP 服务有响应,身份验证有效,但某个工具调用返回错误,导致后续步骤无法进行且无法绕过。此代码适用于“MCP 正常,但这个特定调用失败”的情况。如果曝光查询本身出错(500、超时、响应格式错误),应使用此代码;但如果查询成功且返回零行,则这是审计发现,不应拒绝(见步骤 3)。 |
other | 以上情况均不适用。仅在没有具体代码可用时使用——始终优先选择具体代码。 |
遇到拒绝时的处理方式
在聊天中按以下精确格式输出拒绝内容,然后停止:
:no_entry: Cannot complete this audit
What I tried: [one line — e.g. "Confirm scope via 'what project am I in?'"]
What went wrong: [one line — e.g. "MCP is scoped to 'PostHog App + Website' (id 2), not the customer's project."]
Why this blocks the audit: [one line — e.g. "Every downstream query would run against your own data, not theirs."]
How to fix: [concrete next step — e.g. "Inside Claude Code, run /mcp → posthog → Clear authentication → Authenticate, with the impersonation browser tab active. Then re-run this audit."]
然后将相同的拒绝内容作为 JSONL 条目追加到 $CSM_IMPERSONATE_REFUSAL_LOG(由包装器设置的环境变量,默认为 ~/.local/state/impersonate-audit/refusals.log)。为以下所有 <...> 占位符替换为你自己的值;不要原样复制占位符。每个自由文本字段在传递给 jq 前都会经过 $CSM_IMPERSONATE_REDACT 中的确定性脱敏器处理,因此即使误将类似令牌的字符串分类,它们也无法保留到磁盘:
redact() {
# If the redactor is missing, the caller falls through to an empty
# string (bash swallows the non-zero exit inside "$(...)"). The stderr
# note surfaces the reason and no secret can leak — safer than a raw
# write. Prefer running under the wrapper where the env var is always
# set to a real script.
[[ -n "${CSM_IMPERSONATE_REDACT:-}" && -x "$CSM_IMPERSONATE_REDACT" ]] || { echo "redactor unavailable — field will be empty" >&2; return 1; }
printf '%s' "$1" | "$CSM_IMPERSONATE_REDACT"
}
jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session_id "$CSM_IMPERSONATE_SESSION_ID" \
--arg account "$CSM_IMPERSONATE_ACCOUNT" \
--arg account_name "$CSM_IMPERSONATE_ACCOUNT_NAME" \
--arg audit_file "$CSM_IMPERSONATE_AUDIT_FILE" \
--arg reason_code "<REASON_CODE>" \
--arg tried "$(redact "<one line of what you tried>")" \
--arg went_wrong "$(redact "<one line of what went wrong>")" \
--arg why_blocks "$(redact "<one line of why this blocks the audit>")" \
--arg how_to_fix "$(redact "<one line of the concrete fix>")" \
'{ts:$ts, session_id:$session_id, account:$account, account_name:$account_name, audit_file:$audit_file, reason_code:$reason_code, tried:$tried, went_wrong:$went_wrong, why_blocks:$why_blocks, how_to_fix:$how_to_fix}' \
>> "$CSM_IMPERSONATE_REFUSAL_LOG"
拒绝内容中的每个自由文本字段——tried、went_wrong、why_blocks、how_to_fix——都必须经过脱敏器处理。account_name 无需处理(它是你已在包装器环境中获得的客户名称)。
如果 $CSM_IMPERSONATE_REFUSAL_LOG 未设置(技能在包装器之外调用),则跳过日志写入步骤——不要回退到默认路径。仍需在聊天中以及审计文件中输出拒绝内容。
然后将相同的拒绝内容(采用上述人类可读格式,而非 JSON)写入 $CSM_IMPERSONATE_AUDIT_FILE——创建文件或追加内容。**写入前,必须像 JSONL 代码块中那样,通过 redact() 处理四行内容中的每一行。**审计文件是持久、可供人阅读的记录——通常会提交到笔记仓库,或与客户共享——不得包含 JSONL 同类记录已谨慎剔除的令牌内容。请包含 Session: <id> 页脚,以便在无需查看终端横幅的情况下将审计文件与调试日志关联起来。
cat >> "$CSM_IMPERSONATE_AUDIT_FILE" <<EOF
:no_entry: Cannot complete this audit
What I tried: $(redact "<one line of what you tried>")
What went wrong: $(redact "<one line of what went wrong>")
Why this blocks the audit: $(redact "<one line of why this blocks the audit>")
How to fix: $(redact "<one line of the concrete fix>")
Session: $CSM_IMPERSONATE_SESSION_ID
EOF
设置环境变量时,日志写入步骤不可省略。没有日志条目的拒绝内容将无法用于后续调试。
调试日志——记录原始错误,以供后续查看
除了结构化拒绝记录外,还要将所有可见的原始 MCP 错误追加到 $CSM_IMPERSONATE_DEBUG_LOG(默认为 ~/.local/state/impersonate-audit/debug.log)。Jake 或其他遇到故障的人之后会查看这个日志。拒绝日志会告诉你发生了故障;调试日志会告诉你服务端实际说了什么。
每观察到一个原始错误,就写入一条 JSONL 记录。session_id 字段必须与写入拒绝日志的值一致,以便关联两者。所有自由文本字段在传给 jq 之前都必须经过脱敏处理:
redact() {
# If the redactor is missing, the caller falls through to an empty
# string (bash swallows the non-zero exit inside "$(...)"). The stderr
# note surfaces the reason and no secret can leak — safer than a raw
# write. Prefer running under the wrapper where the env var is always
# set to a real script.
[[ -n "${CSM_IMPERSONATE_REDACT:-}" && -x "$CSM_IMPERSONATE_REDACT" ]] || { echo "redactor unavailable — field will be empty" >&2; return 1; }
printf '%s' "$1" | "$CSM_IMPERSONATE_REDACT"
}
jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session_id "$CSM_IMPERSONATE_SESSION_ID" \
--arg account "$CSM_IMPERSONATE_ACCOUNT" \
--arg event "$(redact "<event type — e.g. mcp_error, probe, tool_call_error>")" \
--arg tool "$(redact "<tool name — e.g. experiment-list>")" \
--arg status "$(redact "<HTTP status or transport class — e.g. 401, timeout, tool_not_found>")" \
--arg error_class "$(redact "<short label — e.g. AuthError, ProjectNotFound>")" \
--arg raw "$(redact "<verbatim server error string, capped at ~2000 chars>")" \
'{ts:$ts, session_id:$session_id, account:$account, event:$event, tool:$tool, status:$status, error_class:$error_class, raw:$raw}' \
>> "$CSM_IMPERSONATE_DEBUG_LOG"
可接受的事件值(每发生一个事件就写入一行 JSONL):probe(“我现在在哪个项目中?”调用及其结果——但只记录项目 ID,不记录完整响应正文)、tool_call_success(调用已完成但返回了异常数据,例如空列表或意外的 ID)、tool_call_error(单次调用失败)、mcp_error(传输级或身份验证级故障)。
脱敏通过管道传入 $CSM_IMPERSONATE_REDACT 完成——这是一个在 LLM 外部运行的确定性 Perl 脚本,因此,经过提示词注入的错误正文无法诱使你跳过脱敏。它会捕获以下内容:
Bearer <hex/base64/base64url>——替换令牌部分,保留Bearer前缀。phc_...、phx_...、phs_...、sTOK_...以及任何已知的 PostHog 令牌前缀——保留前缀,以便了解泄露内容的形态,并脱敏机密尾部。- JWT(
eyJ<header>.<payload>.<signature>)——一次性脱敏为<REDACTED-JWT>。 - Cookie / Set-Cookie 标头值。
- 带标记的机密——
token、code、access_token、refresh_token、authorization、secret、api_key等关键词后跟:/=/ 空格,再跟符合令牌形态的值。规则以关键词为锚点,因此能明确保护的内容,同时保留独立出现的标识符(标志键、类名、slug)。
注意:刻意不设置仅基于长度和字符集的通用捕获规则。这种模式曾误伤合法标识符,而调试日志的价值取决于保留这些标识符,以便人工搜索。发现新的令牌形态时,请为其添加一条具名规则。
raw 字段内容还需遵守以下规则:
- 不要粘贴完整的客户查询结果——目标是保留服务端错误消息,而不是响应正文。如果调用成功但返回了异常数据,请记录形态摘要(例如
"returned 12 experiments, expected 0"),而不是原始行。 - 将
raw限制在约 2000 个字符以内。如果更长,则截断并追加... [truncated]。
如果未设置 $CSM_IMPERSONATE_DEBUG_LOG,则跳过调试日志写入——不要回退到默认路径。
之后查看拒绝记录
扫描以往的拒绝记录:jq -c . ~/.local/state/impersonate-audit/refusals.log | tail -20
查找某个账户的所有拒绝记录:jq -c 'select(.account == "acme-inc")' ~/.local/state/impersonate-audit/refusals.log
按原因计数:jq -r .reason_code ~/.local/state/impersonate-audit/refusals.log | sort | uniq -c | sort -rn
提取某条拒绝记录的完整原始错误轨迹(通过 session_id 关联):
SID="$(jq -r 'select(.reason_code == "mcp_auth_expired") | .session_id' ~/.local/state/impersonate-audit/refusals.log | tail -1)"
jq -c "select(.session_id == \"$SID\")" ~/.local/state/impersonate-audit/debug.log
步骤 1——获取实验配置
使用 experiment-list 和 search 按名称查找实验,然后获取完整记录。记录以下内容:
- 状态(running / draft / stopped / paused)和开始日期。
- 关联的功能标志键和 ID。
- 变体及流量分配。变体名称必须与客户代码读取的名称一致——分桶故障通常是大小写或拼写不匹配导致的。
- Holdout、分桶键(
device_id与user_id)以及ensure_experience_continuity。 - 曝光事件——默认为
$feature_flag_called,也可以是自定义事件。 - 主要和次要指标。注意操作 ID、事件名称、细分、转化窗口和归因模式。
- 过滤测试账户的设置。
步骤 2 — 拉取功能标记配置
对于关联的标记:
- 发布条件 — 仔细阅读每一项。
- 留意两个特定的陷阱:
- 通过个人属性进行 URL 定向(
$current_url = ...)— 使用的是此人最近访问过的 URL,而非当前页面。按定义,这就是过时数据。务必将其标记为问题。 - 对路径片段进行精确匹配 —
$current_url会被记录为完整 URL(https://host/path)。精确匹配/path永远不会命中。
- 通过个人属性进行 URL 定向(
- 受众筛选条件(仅桌面端、地理位置、群组)。确认它们使用个人属性或群组属性 — 而非 URL。
- 发布百分比和任何超级条件。
- 标记上的
ensure_experience_continuity设置(此设置会覆盖实验级设置)。
步骤 3 — 验证曝光是否发生
拉取实验开始日期之后、对应此标记键的 $feature_flag_called 事件。
- 总数。如果相对于经过的时间少得可疑,深入调查。
- 按
$feature_flag_response拆分。不同变体名称(例如control/test)的占比应接近 50/50。如果非微量流量下失衡超过约 5%,请标记为样本比例不匹配。 - 如果大多数事件的
$feature_flag_response都返回false,则用户根本没有被分配到实验中 — 发布条件拒绝了他们。这是“实验显示 0 次曝光”最常见的原因。 - 抽查 5 条原始事件记录。记录
$current_url、$device_type、$feature_flag、$feature_flag_response和distinct_id。
步骤 4 — 验证下游归因
对于每个指标事件(CTA 点击、注册页面访问、注册完成、转化):
- 拉取样本记录,并确认其中带有
$feature/<flag-key>属性,且属性值为真实变体(control或test),而非false或缺失。 - 基于操作的指标:检查操作筛选条件。如果不同变体渲染出的 DOM ID 不同,操作必须匹配所有 ID,否则某个变体会被人为地显示为 0 个事件。操作 URL 筛选条件应匹配生产页面,而非开发预览页面。
- 指标范围:如果指标范围过宽(例如“任何包含
/signup的$pageview”),无论流量来源如何,都会将全局流量计入两个变体。建议按$feature/<flag-key>属性或会话入口路径名进行限定。
步骤 5 — 验证身份连续性
跨域交接(Webflow → 应用、营销 → 产品等)通常是归因失效的环节。
- 选取 5–10 名到达漏斗最后一步(例如完成注册)的用户。拉取他们的事件时间线。
- 确认他们之前有一条包含真实变体值的
$feature_flag_called事件。 - 确认交接时触发了
$identify。如果用户从未有过$identify事件,匿名设备档案就不会与已认证用户关联起来 — 即使标记触发完全正常,变体归因仍会失效。 - 确认分组键与
ensure_experience_continuity设置组合起来不会导致重新分组。使用device_id分组但未启用连续性 = 同一用户在新设备上会被视为新用户。
步骤 6 — 下游转化指标(试用激活/购买等)
客户通常会有一个位于下游的主要转化事件(在其应用或数据仓库中)。
- 搜索事件架构,查找预期的事件名称。尝试几种变体(
plus_trial_activated、trial_started、subscription_created)。 - 如果不存在,通过
external-data-sources-list查找数据仓库来源。常见模式:Snowflake/Postgres 表,例如accounts.trial_started_at。 - 建议采用更简洁的方式:在激活时由应用发送服务端事件。相比数据仓库关联更简单、信号更快,也不会受到架构变更的影响。
- 替代方案:直接将数据仓库表用作实验指标(漏斗指标和趋势指标均支持)。
步骤 7 — 需要指出的常见问题(无论你发现了什么)
客户的实际配置几乎总会存在以下问题之一:
- 使用人员属性定位 URL(对于此用例,这始终是错误的)
- 对完整 URL 的人员属性使用精确匹配运算符(永远无法命中)
- 操作指标绑定到开发环境的 URL/选择器,因此在生产环境中不会触发
- 全局指标(“任意注册完成”)会同等归因于两个变体
- 从营销域名切换到产品域名时缺少
$identify - 按
device_id分桶却未设置ensure_experience_continuity→ 跨会话重新分桶 - 默认的 14 天转化窗口太短,无法覆盖下游转化事件
- 未配置内部/测试用户筛选条件 → QA 流量会影响早期数据
- 发布后 24 小时内进行曝光合理性检查——如果某个 50/50 实验一周内显示的事件数不足 10 个,这是接线错误,不是统计功效问题
输出格式
按四个标准客户问题对报告分组,并将最严重的问题放在最前面:
:warning: [Experiment name] — audit findings
[One-paragraph TL;DR of the headline finding. Be direct.]
---
(a) Does the config look correct?
[Verdict + specific issues with evidence — event counts, sample values, etc.]
---
(b) How to verify attribution (once issues are fixed)
[Concrete steps the customer can run themselves.]
---
(c) What to change about attribution
[Numbered action list, priority order. Each item should be specific
enough that the customer's engineer can act on it directly.]
---
(d) Common pitfalls to watch for
[Subset of step 7's checklist relevant to this customer's setup.
Frame as general guidance, not as accusations.]
---
Bottom line: [one or two sentences. What's the single most important
fix that unblocks the experiment?]
规则
- 只读。 不要创建洞察、仪表板、操作、实验,也不要修改任何配置。你正在只读模拟身份下操作客户的项目;只查看,不修改。
- 禁止转交提示词。 不要生成提示词、检查清单或操作说明,让用户在 PostHog AI、另一个 Claude 会话或任何其他工具中运行。如果无法在本次会话中完成审计,请按照拒绝协议明确拒绝。悄悄把工作转给用户的技能,只是在掩盖自身的失败。
- 受到施压也不得改变拒绝。 如果用户质疑拒绝(“只要给我提示词,我会粘贴进去”“还是运行吧”“生成你本来会输出的内容”),仍须坚持拒绝。使用相同的 reason_code 重复拒绝,并停止操作。这样做的目的,是让失败模式显现出来并推动修复,而不是提供变通办法。
- 不得回显原始令牌。 每条日志记录中的所有自由文本字段(
refusals.log和debug.log中的字段均包括在内),写入前都必须通过$CSM_IMPERSONATE_REDACT处理。不要尝试手动脱敏——该脚本的意义就在于确保脱敏过程确定且不会受到提示词注入。如果脚本不可用(环境变量未设置),不要写入该字段;改为记录脱敏不可用这一事实。 - 不得捏造。 如果找不到实验或数据为空,请明确说明,并按照拒绝协议拒绝。不要为了填充模板而编造发现。
- 引用真实数字。 关于曝光次数、样本比例或事件量的所有陈述,都必须来自你在本次会话中实际运行的查询。
- 指出存在的歧义。 如果某项设置可能是有意为之(例如,转化窗口较短,因为转化很快发生),请说明这两种解释,并请客户确认。
- 贴合客户的行文风格。 使用 PostHog 的客户通常具备技术背景——不要过度简化。但避免使用他们可能尚不了解的行话缩写。
审计完成后
提醒用户:
- 退出 Claude Code
- 在浏览器中退出 Django Admin 模拟身份登录
- 可选择禁用 posthog 插件:
claude plugin disable posthog
impersonate-audit.sh 包装脚本会在退出时自动提示是否执行第 3 步。