Skip to content

feat: add payment and activation analytics events - #718

Merged
AM-young-fun merged 12 commits into
mainfrom
codex/deepseek-v4-pro-campaign-20260813
Aug 13, 2026
Merged

feat: add payment and activation analytics events#718
AM-young-fun merged 12 commits into
mainfrom
codex/deepseek-v4-pro-campaign-20260813

Conversation

@mguozhen

Copy link
Copy Markdown

Summary

  • send explicit topup_success or subscription_success events plus shared payment_success
  • add server-side api_key_created, cli_key_created, and successful playground_used activation events
  • batch related payment events in one GA4 Measurement Protocol request

Validation

  • go test ./service -run 'TestDeliverPaymentAnalyticsEvent' -count=1\n- full controller suite has an unrelated existing pricing fixture failure: failed to build public website pricing: invalid internal ratio

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit 3302704a · 共 5 条

controller/token.go

  • L311-315: [严重] 这里用可由用户提交的 token 名称来判定 cli_key_created,普通手动创建的 key 只要命名为 Flatkey CLI 就会被统计成 CLI 激活事件,导致激活分析数据被污染;而真正的 CLI 授权流程已有独立入口发送 cli_key_created。建议手动创建入口固定发送 api_key_created,或改用服务端可信的来源字段/专用 CLI 创建路径判定。
sendActivationEvent(c, "api_key_created", map[string]any{"key_type": "manual"})
  • L366-370: [严重] 这里同样基于用户可控的名称区分 CLI key,会把名称为 Flatkey CLI 的初始普通 key 误报为 cli_key_created,影响 activation analytics 的准确性。建议初始 token 创建固定发送 api_key_created,若确需区分 CLI,请使用后端创建流程中的可信来源而不是名称。
sendActivationEvent(c, "api_key_created", map[string]any{"key_type": "initial"})

controller/activation_analytics.go

  • L21-24: [严重] 这里把 *gin.Context 传给异步发送逻辑,SendGAEvent 会在 goroutine 中延后使用该 ctx 记录日志;而 gin.Context 会在请求结束后被复用,存在数据竞争或日志上下文串到其他请求的风险。建议只传递不会被复用的标准 context(如 c.Request.Context())或 context.Background()
service.SendGAEvent(c.Request.Context(), service.GAEvent{
		Name: name, ClientID: clientID, SessionID: sessionID,
		TimestampMicros: common.GetTimestamp() * 1_000_000, Params: params,
	})

service/ga.go

  • L192-196: [严重] 这里对批量事件采用“任意一个事件字段为空就直接 return nil”的策略,会静默丢弃整批数据。当前支付分析一次会发送 3 个 GA 事件,只要其中某个事件因为上游数据缺失不完整,另外两个有效事件也不会上报,造成转化/分析数据缺失且调用方无法感知。建议改为跳过无效事件并发送剩余有效事件,或返回明确错误让上层记录/重试。
validEvents := make([]GAEvent, 0, len(events))
	for _, event := range events {
		if event.Name == "" || event.ClientID == "" || event.SessionID == "" {
			continue
		}
		validEvents = append(validEvents, event)
	}
	if len(validEvents) == 0 {
		return nil
	}
	events = validEvents

controller/playground.go

  • L17-19: [严重] 这里把埋点放在 defer 里并只依赖 c.Writer.Status() 判断成功,遇到 Relay 内部 panic 时会在 Gin 恢复中间件改写 500 之前就执行;此时状态码通常还是默认 200,容易把失败请求误报成成功事件,导致激活数据偏高。建议改为在 Relay(...) 正常返回后再根据最终状态发送,或者让下层显式返回成功/失败结果后再埋点。
Relay(c, types.RelayFormatOpenAI)
	if c.Writer != nil && c.Writer.Status() >= 200 && c.Writer.Status() < 300 {
		sendActivationEvent(c, "playground_used", map[string]any{"surface": "playground"})
	}

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 3302704a..b34e71d5 · 共 9 条

web/default/src/features/dashboard/components/overview/create-api-key-dialog.tsx

  • L105-109: [严重] 这里把 API Key 创建成功后的父组件刷新/选中流程纳入同一个 try/catch,如果 onCreated 中的 invalidateQueries 或后续逻辑因网络/查询异常 reject,实际已经创建成功的 key 会被当成“创建失败”:弹窗不关闭且锁释放,用户再次点击会重复创建 key,造成重复密钥和状态不一致。建议将“创建成功”与“刷新/选中失败”解耦,创建接口成功并拿到 id 后先关闭弹窗/清空表单,再单独捕获 onCreated 的异常并提示刷新失败,避免重复提交。
setName('')
      setSubmitted(false)
      props.onOpenChange(false)
      toast.success(t('API key created'))

      try {
        await props.onCreated(createdId)
      } catch {
        toast.error(t(ERROR_MESSAGES.UNEXPECTED))
      }

web/default/src/features/dashboard/components/overview/api-key-picker.tsx

  • L112-127: [严重] 这里把“选中但没有 resolvedKeys”直接等同为解析中。实际解析接口失败或返回空值时,父级 hook 只会 toast,resolvedKeys 仍然没有该 id,本组件会一直显示转圈且没有失败态/重试入口,用户无法判断失败并完成复制。建议从解析 hook 向选择器传入明确的 resolving/error 状态,只有真正请求中才展示 Loader2,失败时展示可重试/错误提示或隐藏加载态。
{fullKey ? (
                  <CopyButton
                    value={fullKey}
                    className='size-6 shrink-0'
                    iconClassName='size-3.5'
                    tooltip={t('Copy API key')}
                    successTooltip={t('Copied!')}
                    aria-label={t('Copy API key')}
                  />
                ) : selected && props.resolvingKeyId === key.id ? (
                  <Loader2
                    className='text-muted-foreground size-3.5 shrink-0 animate-spin'
                    aria-label={t('Loading...')}
                  />

web/default/src/features/dashboard/components/overview/integration-snippets.ts

  • L419-420: [严重] 这里把 resolvedKey 通过函数形式传给了 replaceAll,但 String.prototype.replaceAll 不支持回调替换器,运行时会被当作普通字符串处理,导致复制出来的内容错误,真实 API Key 无法被正确替换。建议直接传入字符串替换值。
if (!resolvedKey) return undefined
  return code.replaceAll(displayedKey, resolvedKey)
  • L0: [严重] 这里的视频示例使用无限轮询,没有最大重试次数、总超时或未知状态处理;一旦任务长期 pending、状态返回异常或任务丢失,示例会一直卡住,导致用户无法完成集成并持续占用运行资源。建议加入总超时/最大轮询次数,并对除 succeeded/failed 之外的状态显式抛错。
if (data.status === 'succeeded') return data
    if (data.status === 'failed') throw new Error('Generation failed')
    throw new Error(`Unexpected task status: ${data.status}`)

web/default/src/features/dashboard/components/overview/overview-dashboard.tsx

  • L226-229: [严重] 当 availableModels 为空时,pickDefaultModel([]) 会返回硬编码的 gpt-4o-mini,随后弹窗仍会基于这个模型生成可复制示例。对于只开放 embedding/TTS/video-to-music 等不可演示模型的分组,或模型元数据导致全部被过滤的场景,用户会复制到当前 Key 无权限/不可用的模型请求,核心接入示例会直接失败。建议不要在无可用模型时回退到硬编码模型,而是让弹窗进入“无可用演示模型”的禁用/提示状态,或只从实际 availableModels 中选择模型。
const exampleModel = useMemo(() => {
    if (availableModels.length === 0) return ''
    const fallbackModel = pickDefaultModel(availableModels)
    return resolveSnippetModel(selectedModel, availableModels, fallbackModel)
  }, [availableModels, selectedModel])
  • L101-113: [严重] serverAddress 仅从 status.server_address/serverAddressstatus.data 中读取,但当前 useApiInfo() 返回的是 api_info 列表数据。若后端仍按旧结构通过 api_info[0].url 提供网关地址,这里会解析为空并回退到 window.location.origin,生成的示例 endpoint 将指向控制台域名而不是实际 API 网关,导致用户按示例接入失败。建议补充对 api_info 旧字段的兼容解析,或统一把网关地址暴露在明确的 status 字段中后再切换。
const serverAddress = useMemo(() => {
    const statusRecord = status as Record<string, unknown> | null
    const nestedData =
      statusRecord?.data && typeof statusRecord.data === 'object'
        ? (statusRecord.data as Record<string, unknown>)
        : undefined
    const apiInfo = Array.isArray((statusRecord as any)?.api_info)
      ? ((statusRecord as any).api_info as Array<Record<string, unknown>>)
      : Array.isArray((nestedData as any)?.api_info)
        ? ((nestedData as any).api_info as Array<Record<string, unknown>>)
        : []
    const value =
      statusRecord?.server_address ??
      statusRecord?.serverAddress ??
      nestedData?.server_address ??
      nestedData?.serverAddress ??
      apiInfo[0]?.url
    return typeof value === 'string' ? value : undefined
  }, [status])

web/default/src/features/dashboard/components/overview/use-resolved-api-keys.ts

  • L78-85: [阻塞] 这里把解析出的明文 key 直接写入本地状态,但没有绑定/校验发起请求时的 userId。若用户在请求返回前登出或切换账号,旧账号的异步回包仍会把明文 key 写回当前 state,造成跨账号敏感信息泄漏。建议把 userId 一并带回并在写入前校验仍属于同一用户,或在账号切换时取消/丢弃未完成请求的结果。
useEffect(() => {
    if (!resolved || resolved.userId !== userId) return
    setResolvedState((prev) =>
      prev.userId !== userId || prev.keys[resolved.id] === resolved.key
        ? prev
        : { ...prev, keys: { ...prev.keys, [resolved.id]: resolved.key } }
    )
  }, [resolved, userId])

web/default/src/i18n/locales/ja.json

  • L0: [阻塞] 这里把“上游成本无法解析/计费快照缺失”直接降级成 keepReservedQuota,会让解析失败、字段变更、空 body 或持久化损坏都静默跳过结算,最终按预扣上限长期保留额度,造成明显的资金/计费错误。建议把这类情况单独上报为失败/待重试,而不是和正常“保留预扣”合并处理。
func completedQuota(task *model.Task) (int, error) {
	if task == nil || task.PrivateData.BillingContext == nil {
		return 0, fmt.Errorf("billing snapshot is missing")
	}
	usd, ok := parseUpstreamCost(task.Data)
	if !ok {
		return 0, fmt.Errorf("upstream reported no usable usage.cost_in_usd_ticks")
	}
  • L0: [严重] int(quota) 前没有做上界保护,若上游返回异常大的 cost_in_usd_ticks 或出现数据污染,浮点到整数转换可能溢出并产生错误 quota,直接导致计费偏差。建议在转换前显式校验 quota <= float64(math.MaxInt),超限时返回失败/保持预扣的可观测分支。
func settledQuotaFromCost(usd, groupRatio float64) int {
	if !isPositiveFinite(usd) || !isPositiveFinite(groupRatio) {
		return 0
	}
	quota := usd * grokMarkup * common.QuotaPerUnit * groupRatio
	if !isPositiveFinite(quota) || quota > float64(math.MaxInt) {
		return 0
	}
	return int(quota)
}

@mguozhen

Copy link
Copy Markdown
Author

One Tap staging incident follow-up

  • Symptom: Google credential selection completed, but the browser returned to /sign-in?redirect=%2Fdashboard.
  • Evidence: Cloud Run request trace showed POST /api/oauth/google/one-tap returned 303; the failure helper would redirect to /sign-in?provider=google, so token validation was not the failing branch. The observed URL therefore came from /dashboard rejecting the missing session.
  • Root cause: the credential is posted cross-site from accounts.google.com; redirecting directly from that POST to /dashboard keeps the navigation in a cross-site redirect chain, so the global SameSite=Strict session cookie is withheld.
  • Fix (4305d5807): keep the global Strict cookie policy, but return a no-store same-origin HTML success document that starts a fresh navigation to the validated internal return path. Return paths remain sanitized and are HTML-escaped.
  • Validation: go test ./controller -run TestGoogleOneTap -count=1 passes; staging build, candidate health check, 100% traffic promotion, and final health check succeeded in Actions run 31746392545. Final Google account selection requires an interactive staging check.

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 4305d580..f94f8334 · 共 1 条

controller/oauth.go

  • L344-362: [严重] 这里会把 respondGoogleOneTapSuccess 的所有返回值都写入 localStorage.user。但上面已有登录分支传入的是 gin.H{"already_logged_in": true},这会把前端原本的用户信息覆盖成一个只有布尔标记的对象,导致后续依赖 user.id/username 的页面和权限判断失效。建议只在确认为真实用户对象时写入本地存储,或者对 already_logged_in 单独分支处理后直接跳转。
if _, ok := data.(gin.H); ok {
		c.Redirect(http.StatusSeeOther, returnPath)
		return
	}
	userJSON, err := common.Marshal(data)
	if err != nil {
		respondGoogleOneTapFailure(c, http.StatusInternalServerError, i18n.T(c, i18n.MsgUserSessionSaveFailed))
		return
	}
	encodedUser := base64.StdEncoding.EncodeToString(userJSON)
	encodedReturnPath := base64.StdEncoding.EncodeToString([]byte(returnPath))
	escapedReturnPath := html.EscapeString(returnPath)
	c.Header("Cache-Control", "no-store")
	c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(
		"<!doctype html><html><head><meta charset=\"utf-8\">"+
			"<title>Signing in</title></head><body>"+
			"<script>(function(){"+
			"var user=JSON.parse(atob('"+encodedUser+"'));"+
			"localStorage.setItem('user',JSON.stringify(user));"+
			"if(user&&user.id!=null)localStorage.setItem('uid',String(user.id));"+
			"location.replace(atob('"+encodedReturnPath+"'));"+
			"})();</script>"+
			"<a href=\""+escapedReturnPath+"\">Continue</a></body></html>",)

@mguozhen

Copy link
Copy Markdown
Author

Follow-up to #718 (comment)controller/token.go CLI classification findings

Resolved in dac907f99:

  • cli_key_created is now derived from the persisted server-side marker Token.Source == model.TokenSourceCLI, not the display name.
  • The public AddToken and EnsureInitialToken paths now drop client-supplied source, device_id_hash, client_name, client_version, and last_used_client_at, so callers cannot forge CLI provenance by posting source: "cli".
  • The dedicated CLI device authorization path uses a separate trusted builder that preserves the server-populated CLI metadata.
  • A manually created key named Flatkey CLI is therefore recorded as api_key_created; a CLI token remains cli_key_created even if renamed.

Validation: go test ./controller -run 'Test(TokenActivationEventName|BuildTokenForInsert|CliDeviceAuthorization)' -count=1 passed.

@mguozhen

Copy link
Copy Markdown
Author

Follow-up to the latest One Tap review: fixed in 2615c0c62.

The finding was valid: the already-authenticated branch passed {already_logged_in: true} through the same HTML renderer used for a newly authenticated user, which could overwrite localStorage.user and leave uid/permissions inconsistent.

The suggested broad gin.H type check was not used because the normal successful login payload is also map-shaped. Instead, the already-authenticated branch is explicit: JSON clients still receive {already_logged_in: true}, while the browser response preserves the existing user/uid storage and only starts the required fresh same-origin navigation for the Strict session cookie.

Regression coverage: TestGoogleOneTapAlreadyLoggedInPreservesStoredUser asserts no user/uid writes and verifies navigation; existing successful-login storage and escaping tests remain green.

Validation: GOCACHE=/private/tmp/flatkey-go-cache go test ./controller -run TestGoogleOneTap -count=1.

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 f94f8334..2615c0c6 · 共 1 条

controller/cli_device_authorization.go

  • L217: [严重] 这里把激活事件完全改成依赖 token.Source,但 buildTokenForInsert 会把普通创建流程里的 Source 去掉,而 EnsureInitialToken 传入的 createdToken 也来自这个清洗后的对象。结果是原先通过名称识别出来的 CLI 初始化 Token 现在都会被统计成 api_key_created,导致激活分析数据回归且与历史口径不一致。建议为“初始 Token 创建”保留可信来源标记,或至少在该路径继续沿用旧的 CLI 判定逻辑。
func tokenActivationEventName(token *model.Token) string {
	if token != nil && token.Source == model.TokenSourceCLI {
		return "cli_key_created"
	}
	if token != nil && strings.EqualFold(strings.TrimSpace(token.Name), "Flatkey CLI") {
		return "cli_key_created"
	}
	return "api_key_created"
}

@AM-young-fun

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 f94f8334..2615c0c6 · 共 1 条

controller/cli_device_authorization.go

  • L217: [严重] 这里把激活事件完全改成依赖 token.Source,但 buildTokenForInsert 会把普通创建流程里的 Source 去掉,而 EnsureInitialToken 传入的 createdToken 也来自这个清洗后的对象。结果是原先通过名称识别出来的 CLI 初始化 Token 现在都会被统计成 api_key_created,导致激活分析数据回归且与历史口径不一致。建议为“初始 Token 创建”保留可信来源标记,或至少在该路径继续沿用旧的 CLI 判定逻辑。
func tokenActivationEventName(token *model.Token) string {
	if token != nil && token.Source == model.TokenSourceCLI {
		return "cli_key_created"
	}
	if token != nil && strings.EqualFold(strings.TrimSpace(token.Name), "Flatkey CLI") {
		return "cli_key_created"
	}
	return "api_key_created"
}

这条新的 review 不对,不建议照改。
原因:
真正的 CLI 创建流程走 ApproveCliDeviceAuthorization,并通过 buildCLITokenForInsert 写入服务端可信的 Source == "cli"。
EnsureInitialToken 是控制台新用户自动创建初始 Key 的接口,前端传入的默认名称是 default,不是 Flatkey CLI。
普通创建和初始创建中的 source 都是客户端可伪造字段,所以必须清除。
它建议重新用名称兜底,会把用户手动创建、命名为 Flatkey CLI 的 Key 错记成 cli_key_created,等于恢复我们刚修掉的数据污染问题。
所谓“与历史口径不一致”也不能作为理由,因为历史的名称口径本身就是错误且可伪造的。
因此当前分类是正确的:
服务端 CLI 授权创建,Source == cli → cli_key_created
控制台手动或自动初始创建 → api_key_created
Key 后续改名也不会改变来源分类
这次无需改代码,也不需要重新发布 staging。最新 review 可以直接回复为误报。

@AM-young-fun
AM-young-fun merged commit 92987e3 into main Aug 13, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants