Voyager 业务线学习:PostHog 埋点可信边界、数据最小化与积分不足可观测性
这条业务线处理的是“用户确实撞到了积分门槛,但产品和分析系统是否能在正确的业务边界看见它”。原来的信号主要来自升级弹窗或 Stripe 回跳
source_automation: voyager-merged-pr run_date: 2026-07-31 anchor_pr_number: 5455 pr_number: 5455 pr_title: “feat(analytics): track insufficient credit encounters” pr_url: https://github.com/adastralab-ai/voyager/pull/5455 ↗ author: AaronJan merged_at: “2026-07-31T01:41:07Z” modules:
- packages/site/src/app/(main)/_lib
- packages/site/src/app/(main)/_billing
- packages/site/src/app/(main)/_agent/chat
- packages/site/src/app/(main)/_photoEditor
- packages/site/src/app/(main)/brands
- packages/site/src/app/(main)/files/[id]
- backend/go/internal/brand
- backend/go/apps/api/handler
- idl/typespec/services/brand.tsp
- generated API clients and types files_changed: 23 changed_files:
- backend/go/apps/api/handler/brand.go
- backend/go/internal/brand/service.go
- backend/go/internal/brand/service_import.go
- backend/go/internal/brand/service_privacy_test.go
- backend/go/pkg/gen/api/types.gen.go
- backend/workers/agent/src/gen/api/types.gen.ts
- idl/openapispecs/api.yml
- idl/typespec/services/brand.tsp
- packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.test.tsx
- packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx
- packages/site/src/app/(main)/_agent/chat/_components/AgentChatErrorMessage.tsx
- packages/site/src/app/(main)/_agent/chat/_lib/agentChatTypes.ts
- packages/site/src/app/(main)/_billing/_lib/handleInsufficientCredits.test.ts
- packages/site/src/app/(main)/_billing/_lib/handleInsufficientCredits.ts
- packages/site/src/app/(main)/_lib/insufficientCreditsTracking.ts
- packages/site/src/app/(main)/_photoEditor/_hooks/usePhotoImageEdit.tsx
- packages/site/src/app/(main)/brands/_hooks/useGenerateShowcases.ts
- packages/site/src/app/(main)/brands/new/_components/ImportProgress.tsx
- packages/site/src/app/(main)/brands/new/page.tsx
- packages/site/src/app/(main)/files/[id]/_hooks/useImageEdit.tsx
- packages/site/src/app/_lib/trackerEvents.ts
- packages/site/src/gen/api/types.gen.ts
- packages/site/src/gen/api/zod.gen.ts learning_tags:
- analytics-boundary
- insufficient-credits
- data-minimization
- posthog
- agent-turn-gate
- async-task-result
- typespec-contract
- best-effort-workflow
- cross-layer-api
- test-strategy business_line: “PostHog 埋点的可信边界、数据最小化与积分不足可观测性” related_prs: [5074, 5426, 5453] line_stage: “移除不可信前端计费结果 -> 清理 stale plan 属性 -> 环境级 telemetry opt-out -> 积分不足业务失败边界统一观测” open_questions:
- “billing:insufficient_credits_encountered 是 encounter 事件而非去重后的用户动作;重试、多个 tool part、页面 reload 或 replay 是否会放大指标,当前没有 event/action id 或去重规则。”
- “Agent onFinish 会扫描一个最终消息中的多个失败 image tool part;需要用 reconnect、replay 和 regenerate 场景确认是否可能重复上报同一个失败。”
- “品牌导入的 paid showcase 失败被 best-effort 吞掉,只把已知 insufficient_credits 作为可选字段返回;其他失败不会进入同一事件,且需要确认用户是否得到足够清晰的 UI 反馈。”
- “PostHog opt-out 依赖 SDK 的 opt_out_capturing_by_default;trackEvent 本身没有额外分支,需要在启用 feature flag 的 Dev 环境确认 /flags 仍工作且 capture、replay、identify 均不发送。”
- “source/operation 是前端手工维护的有限联合类型;新增图片能力、改名或增加后端错误码时,必须同时更新映射、分析口径和测试。”
- “前端事件仍受 ad blocker、未初始化 SDK 和匿名环境影响;如果该指标要支撑计费/收入事实,仍需服务端或 Warehouse 口径,而不能把本事件当作授权或扣费记录。” feishu_doc_url: null github_repository: joyehuang/ai-agent-field-notes github_path: outputs/voyager-daily-pr-study/2026-07-31-pr-5455-insufficient-credits-telemetry-boundary.md voyager_merge_commit: ac8b83db47b6674d4dd3151faeaba6dd4ec3a150 voyager_origin_main_snapshot: 541b49b211b04274fa7bc973ad178b54790746ec
业务线概览#
这条业务线处理的是“用户确实撞到了积分门槛,但产品和分析系统是否能在正确的业务边界看见它”。原来的信号主要来自升级弹窗或 Stripe 回跳:
- 设计编辑器、Photo Editor、品牌流程和 Agent 的失败形态不同,有的在提交时返回 402,有的在异步 task 终态才出现;只观察 Plan Dialog 会漏掉等待余额刷新、Agent turn gate 和 tool failure。
- Stripe 回跳后的浏览器状态不能证明支付事实。#5074 移除了前端根据
subscriptionStatus推断“支付成功/试用开始”的事件,但保留状态同步;#5426 又移除了过时的前端planperson 属性,把套餐分析交给已同步的 Warehouse 数据。 - Dev 需要真实 PostHog feature flags,却不希望把开发访问写进 analytics、person profile 或 session replay。#5453 增加部署级 opt-out,但保留 SDK 初始化和 flag 请求路径。
今日锚点 #5455 把“积分不足”提升为统一业务失败事件 billing:insufficient_credits_encountered。事件只携带 source 和 operation,不携带余额、套餐、补救动作或请求 ID;它是“用户遇到业务阻断”的分析事件,不是授权、扣费或收入事实。
本文的代码事实来自 Voyager origin/main snapshot 541b49b211b04274fa7bc973ad178b54790746ec,并对照 PR 的真实 diff;行号均以该 snapshot 为准。四个正式 PR 的 merge commit 都已验证为 origin/main 祖先。
今日锚点#
| 字段 | 内容 |
|---|---|
| PR | #5455 feat(analytics): track insufficient credit encounters ↗ |
| 作者 | AaronJan |
| merge 时间 | 2026-07-31 11:41:07 AEST / 2026-07-31T01:41:07Z |
| Voyager merge commit | ac8b83db47b6674d4dd3151faeaba6dd4ec3a150 |
| 变更规模 | 23 个文件,442 additions / 78 deletions |
| 选择理由 | 今日 Melbourne 窗口内最完整的业务线候选:同一事件贯穿 4 类产品入口、Agent 的两种终态、品牌异步工作流的 API 契约和多层测试,能自然串起前置治理与同日 runtime 控制。 |
演进时间线#
| 阶段 | PR 与真实代码变化 | 改变的层 | 形成的契约 |
|---|---|---|---|
| 可信来源收敛 | #5074 ↗,2026-07-22,merge 2e8019728dee3809b589362087c4d433133eed00 | Site billing / analytics | 删除 useCheckoutSync 中根据浏览器回跳和当前订阅状态上报支付成功、试用开始的分支;保留 BillingService.Sync、query invalidation 和 URL 清理。计费事实回到 Stripe/Warehouse。 |
| 数据最小化 | #5426 ↗,2026-07-30,merge 79d1f11853ce3cb5c9eeee7e85e6e4b86a127e57 | Site identity / PostHog person properties | 删除 billing effect 对 plan 的二次 setUser;保留 auth effect 的 email/name identity 和 userRole super property,避免把滞后且可错的前端套餐快照当作用户属性。 |
| 环境级控制 | #5453 ↗,2026-07-31,merge 868db4552d821d9953981b2220be2d38a53404c6 | PostHog SDK boundary | NEXT_PUBLIC_POSTHOG_TELEMETRY_DISABLED=true 时仍初始化 SDK 以取 feature flags,但通过 opt_out_capturing_by_default、disable_external_dependency_loading 和 person_profiles: "never" 关闭 telemetry。 |
| 今日锚点:业务失败观测 | #5455 ↗,2026-07-31,merge ac8b83db47b6674d4dd3151faeaba6dd4ec3a150 | Product failure boundary / API contract / tests | 在通用错误处理、Agent onError/onFinish、品牌 finalize response 和多个产品入口统一记录 source + operation,而不要求 Plan Dialog 出现。 |
相邻的 #5444 ↗ 只升级 posthog-js 到 1.407.3。它是同一时间段的依赖背景,但属于机械升级,没有作为正式 related PR 计入主线;报告只在 opt-out 的兼容性风险中保留它。
当前架构与数据流#
flowchart LR
A[设计编辑器 / Photo Editor] --> B[402 API 或异步 task 失败]
C[Agent turn gate] --> D[useAgentChat onError]
E[Agent image tool] --> F[useAgentChat onFinish]
G[品牌导入] --> H[Go FinalizeImport]
H --> I[可选 showcaseErrorCode]
B --> J[typed tracking context]
D --> J
F --> J
I --> J
J --> K[trackInsufficientCreditsEncountered]
K --> L[common tracker]
L --> M[PostHog capture]
N[NEXT_PUBLIC_POSTHOG_TELEMETRY_DISABLED] --> Mplaintext- 设计编辑器与 Photo Editor:
useImageEdit.tsx和usePhotoImageEdit.tsx在 API 402 或 taskerrorCode=insufficient_credits的错误边界构造{ source, operation },交给handleInsufficientCredits或handleTaskCreditsFailure。同一处仍负责升级弹窗、余额刷新提示和通用错误抑制。 - Agent turn:turn gate 的 JSON error 由共享的
parseAgentChatError识别;AgentChatContext.tsx:1082-1097在onError记录agent_turn。图片工具不是普通异常,而是在最终 UI message 的 tool part 中以output.status=failed、output.reason=credits出现;onFinish扫描并按 tool 类型映射到image_generation、image_edit、remove_background或cutout_generation。 - 品牌导入:初始 import API 失败走前端 402 handler;import 成功后自动启动的 paid showcase 则由 Go
FinalizeImportbest-effort 提交。后者若为已知积分不足,不让品牌创建失败,而是在FinalizeBrandImportResponse.showcaseErrorCode返回insufficient_credits,ImportProgress收到响应后记录brand_showcase。 - 分析出口:所有入口最终调用
@repo/common/tracker的trackEvent。PostHog 没有 token 时初始化 no-op;配置 opt-out 时 SDK 仍初始化,但 capture 默认关闭、外部依赖不加载、person profile 禁用,feature flag 仍可单独工作。 - 事实边界:#5074 和 #5426 已把支付/套餐的“权威事实”移出浏览器属性;#5455 的 event 只描述客户端遇到阻断,不能替代 Go credits ledger、Stripe event 或 Warehouse join。
关键代码#
1. 用有限类型把分析维度固定在业务词汇上#
来源:PR #5455 ↗,packages/site/src/app/(main)/_lib/insufficientCreditsTracking.ts:6-39。
export type InsufficientCreditsSource =
| "agent"
| "brand"
| "design_editor"
| "photo_editor";
export interface InsufficientCreditsTrackingContext {
source: InsufficientCreditsSource;
operation: InsufficientCreditsOperation;
}
export function trackInsufficientCreditsEncountered(
context: InsufficientCreditsTrackingContext,
) {
tracker.trackEvent(TRACKER_EVENTS.billing.insufficientCreditsEncountered, {
source: context.source,
operation: context.operation,
});
}plaintext设计点不是“抽一个 tracker helper”,而是把事件 schema 变成编译期约束:调用方不能随意把余额、套餐或后端请求 ID塞进事件;remove_bg 也通过 getImageEditInsufficientCreditsOperation 统一成分析名 remove_background。代价是每个新能力都必须显式更新 union、映射和测试。
2. 在业务错误边界记录,而不是等 UI 补救动作#
来源:PR #5455 ↗,packages/site/src/app/(main)/_billing/_lib/handleInsufficientCredits.ts:26-44,60-84。
if (!(err instanceof BillingInsufficientCreditsError)) return false;
trackInsufficientCreditsEncountered(trackingContext);
const payload = err.raw.details?.[0];
if (!payload) return false;
if (payload.canUpgrade) {
showPlanDialog("insufficient_credits");
return true;
}plaintext事件在读取可选 details[0] 之前发生,所以即使 402 没有补救 payload,也能记录一次真实的积分阻断;之后才分流到升级弹窗或余额刷新提示。task 路径只对 err.code === "insufficient_credits" 记录,再复用已有 remediation。新增的 handleInsufficientCredits.test.ts:30-114 覆盖了可升级 402、缺少 details 的 402 和轮询 task 失败三种输入。
3. Agent 的 turn gate 与 tool failure 使用不同终态入口#
来源:PR #5455 ↗,packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx:358-390,1082-1097。
onFinish: ({ message }) => {
trackAgentToolInsufficientCredits(message);
void queryClient.invalidateQueries({
queryKey: agentChatListQueryKeyPrefix(workspaceId),
});
},
onError: (chatError) => {
if (parseAgentChatError(chatError).code === "insufficient_credits") {
trackInsufficientCreditsEncountered({
source: "agent",
operation: "agent_turn",
});
}
},plaintext这里没有把所有 Agent 失败都压成一个事件:turn gate 是 transport/error channel,图片工具失败是已完成的 UI message part。AgentChatContext.test.tsx:525-584 分别构造 JSON turn gate error 和 tool-image_generation 的 credits output,断言事件 operation 不混淆。这个做法复用了 Agent 已有的结构化终态,但也把去重责任留给了运行时语义:replay 或 regenerate 是否再次触发 onFinish,当前 diff 没有覆盖。
4. 异步品牌 showcase 失败不应回滚品牌创建#
来源:PR #5455 ↗,backend/go/internal/brand/service_import.go:500-521、backend/go/apps/api/handler/brand.go:253-274。
showcaseTask, err := s.submitShowcaseTask(...)
if err != nil {
var apiErr *bizerrors.APIError
if errors.As(err, &apiErr) && apiErr.Code == api.ErrCodeBillingInsufficientCredits {
showcaseErrorCode = api.InsufficientCredits
}
s.LogOnce(ctx).Warn().Err(err).Msg("Failed to start brand showcase generation after import")
} else {
showcaseTaskID = showcaseTask.ID
}plaintextFinalizeImport 的主成功语义是品牌已创建;paid showcase 是后续副作用,失败只进入 warning,并把“已知的积分不足”作为可选 API 字段返回。idl/typespec/services/brand.tsp:143-155 定义了这个可选枚举,Go/worker/site 的生成类型随之更新;ImportProgress.tsx:119-129 在收到字段后记录 brand_showcase。这比前端猜测 task 状态更可靠,但只暴露一个已知错误码,其他 showcase 失败仍是非结构化 warning。
5. opt-out 是 SDK 初始化策略,不是让 feature flag 失效#
来源:PR #5453 ↗,当前 packages/site/src/app/_vendors/posthog/client.ts:22-48、clientEnvironment.ts:29-37。
posthog.init(token, {
defaults: "2026-05-30",
disable_external_dependency_loading: isTelemetryDisabled,
opt_out_capturing_by_default: isTelemetryDisabled,
person_profiles: isTelemetryDisabled ? "never" : "identified_only",
});
export function identifyUser(distinctId: string, personProperties?: TrackingProperties) {
if (!isInitialized || isTelemetryDisabled) return;
posthog.identify(distinctId, personProperties);
}plaintext配置缺失时继续保持旧行为;配置为 true 时仍有 token 和 SDK 初始化,但 capture/replay/person profile 不应产生数据。新增的 client.test.ts 验证了两种 init option 和 identify 是否调用;真正的 Network 请求、feature flag response 与生产构建仍需要环境测试。
6. 前置治理删除了“看起来方便但不可信”的指标#
来源:PR #5074 ↗ 与 PR #5426 ↗。
- #5074 从
packages/site/src/app/(main)/_billing/_hooks/useCheckoutSync.ts删除回跳后的两个trackEvent分支,但保留BillingService.Sync、缓存失效和checkoutquery 清理。页面仍能刷新套餐和 credits,分析不再把浏览器返回状态当作支付事实。 - #5426 从
packages/site/src/app/_components/AnalyticsCoordinator.tsx删除 billing 完成后的重复tracker.setUser(userId, { email, name, plan });当前:92-102只同步userRolesuper property。套餐从 PostHog Warehouse 的user_account/Stripe 数据读取,避免 stale plan 和 billing race 造成错误 breakdown。
两项变化为 #5455 设定了分析边界:业务事件可以回答“用户在哪个入口撞到积分门槛”,但支付成功和当前套餐仍必须查询权威数据源。
工程取舍#
边界与复用#
- 统一的是事件契约,不是所有产品流程。
handleInsufficientCredits复用 402 处理,task、Agent 和品牌 finalize 仍保留各自真实错误形态;调用方只在边界处补充 context。 - 产品成功与后续付费副作用分离。 品牌先完成导入,再把 showcase 的已知失败暴露为 optional field;这避免积分不足把已创建的 Brand 误报成整个 import 失败。
- 分析字段主动收窄。
source与operation足够做入口/能力分布,但不能回答余额、套餐、成本或补救转化;这些维度需要独立的权威或关联数据。
兼容性与 API 契约#
showcaseErrorCode是 optional,旧客户端可以忽略,旧的showcaseTaskId语义保留。- TypeSpec 是新增字段的源头,OpenAPI、Go、site 和 worker 类型一起变化;当前 diff 同时包含生成产物,后续若只改其中一层会产生 codegen drift。
- Agent 的 JSON turn gate parser 从
AgentChatErrorMessage提到agentChatTypes.ts,避免显示层和 telemetry 层各自解析一遍错误 envelope。 #5074、#5426的兼容策略是保留用户可见的 billing sync 和 auth identity,只删除不可信的 analytics side effect,不把治理变更扩大成产品流程重写。
可靠性与性能#
- 事件调用在客户端失败路径上是同步调用,但不阻塞升级弹窗、toast、task polling 或 Agent transcript;分析失败不应改变业务补救。
- Agent 一条 message 可能包含多个失败 tool part,代码按 part 上报,适合统计每次 tool encounter,但如果指标被误解为每个 turn 或用户,需要在 Warehouse 层重新聚合。
onFinish扫描完整 message parts 的成本与普通 Agent message 规模绑定;当前只检查四种 image tool 类型,没有遍历任意 payload。- PostHog opt-out 通过 SDK 配置减少外部依赖和遥测流量,但
trackEvent的调用点仍存在,系统正确性依赖 SDK 版本和初始化 option 的实际行为。
测试策略#
- #5455 新增通用 billing handler 单元测试,覆盖 402 有/无 details 与异步 task error;Agent context 测试覆盖 turn gate 和 image tool 两个入口。
- #5453 新增 PostHog client init 单元测试,验证默认开关和 opt-out option;#5426 保留 identity/super-property 顺序相关回归测试;#5074 的验证重点是 checkout UI/URL 保持工作且两个旧事件不再出现。
- 代码 diff 没有看到针对 Go
FinalizeImport返回showcaseErrorCode的专门 service/integration test,也没有真实 PostHog Network、feature flag 和 replay 的自动覆盖。PR body 的测试计划仍依赖 Dev/Preview 手工验证。
和最近学习记录的关系#
最近一条学习是 2026-07-30 #5425:Agent turn 异常终态、可续跑与用户可见恢复。两条线共享 Agent 的结构化终态边界,但目的不同:
- #5425 关心
finishReason、refused/interrupted优先级和 Continue affordance,决定用户下一步能否继续;#5455 只观察onError的 turn gate 和onFinish的 tool result,不改变 turn 状态机。 - #5425 的 replay/reconnect 风险直接影响 #5455 的重复上报风险:如果恢复流程再次交付同一失败 tool part,telemetry 可能把一次阻断算成多次 encounter。
- 2026-07-23 的 #5107 eval 成本与生产 prompt 一致性报告强调“生产事实先归一化,分析层再做转换”;#5455 延续了同一个原则,只是把对象从 token usage 换成业务失败事件。
GitHub 是本线的唯一持久化报告来源;不创建、不更新、不读取个人 Feishu/Lark 学习文档,feishu_doc_url 固定为 null。
我会怎么吸收#
- 把指标埋在业务失败/状态转换边界,而不是埋在升级弹窗、toast 或某个当前 UI 组件上;UI 改版不应改变事件覆盖。
- 为跨产品事件先定义有限的
source/operation词汇,再让调用方传 typed context;不要让每个入口自由拼属性。 - 把“权威事实”和“用户遇到的业务阻断”拆成两套数据模型:前者来自服务端/Warehouse,后者可以来自客户端事件。
- 对异步流程先定义主成功和后续副作用的边界,再用 optional error code 把可行动的部分传回客户端;不要因为一个付费子任务失败就回滚主资源。
- 测试 telemetry 时同时覆盖产品补救行为、结构化失败输入和 SDK opt-out;只 mock
trackEvent不能证明真实请求没有发出。
边界、风险与未解问题#
代码验证事实#
- #5455 只上报
source与operation;没有余额、套餐、remediation 或请求 ID。 - 402 handler 在解析 details 前上报;task handler 对
insufficient_credits上报后继续走既有 remediation。 - Agent turn gate 和 image tool failure 走不同 callback;品牌 showcase 的积分失败经 Go optional response field 返回。
- #5453 只在
NEXT_PUBLIC_POSTHOG_TELEMETRY_DISABLED === "true"时切换 SDK 初始化选项;#5426 当前保留userRolesuper property;#5074 当前仍保留 checkout sync。
合理推断#
- 业务方应该把该事件理解为“遇到过阻断”的计数,不应直接用它计算扣费失败率、收入或真实余额不足用户数。
- 使用
onFinish扫描 message parts 的实现更接近 Agent 当前 UI 事实,但恢复、regenerate 或跨 tab 重放时可能需要额外幂等层。 - 品牌流程的 optional error code 主要是让 telemetry 不必猜测异步任务失败原因;它不是完整的用户-facing failure protocol。
仍待确认#
- 需要真实 Dev/Preview 网络检查:opt-out 下 feature flag 是否成功、capture/replay/identify 是否完全没有上传,以及未设置变量时旧行为是否保持。
- 需要用 Agent reconnect、replay、regenerate、多个 image tool failure 和跨 tab 场景确认 event 是否重复;当前测试只验证单一 callback 输入。
- 需要确认品牌 showcase 非积分失败的产品提示、日志告警和数据分析是否有统一契约;当前 Go 只返回
insufficient_credits这一枚举。 - 需要定义事件去重/聚合口径:按 API encounter、tool part、turn、session 还是 user 计数;否则同一用户的多次 retry 会影响业务判断。
- 需要确认 generated API 文件是否由本次 TypeSpec 变更的标准 codegen 产出,避免后续手工改 generated file 导致 drift。
候选说明#
今日 Melbourne 窗口有 6 个已合入候选:
| 候选 | 处理 |
|---|---|
| #5455 积分不足统一观测 | 选为锚点;跨前端入口、Agent 终态、Go API/schema 和测试,业务线最完整。 |
| #5453 PostHog telemetry opt-out | 作为同日并行/前置阶段;边界清晰,但单独报告会偏向 4 文件 SDK 配置,无法覆盖积分业务流程。 |
| #5480 / #5466 editor-api Docker 启动修复 | 两个 PR 的关系明确,但总 diff 只有 3 + 12 行,主要是 workspace package.json copy/mount 清单;若以它们为锚点,业务线在本窗口基本是孤立的局部修复。 |
| #5443 normalized email backfill | 与 #5381 的 auth 数据迁移关系清楚,但属于独立的数据修复/回滚治理线,和今日 PostHog/credits 主题没有代码边界重合。 |
| #5392 drop preview | 自包含的设计交互改动,真实行为值得看,但没有自然的同主题前置/后续 PR。 |
由于今天已经找到足够强的锚点,没有扩大到更早日期;日志中已作为锚点学习过的 PR(包括最近的 #5425)均未重复选择。