feat(ispx): add request-level AI tracing via a JS hook - #3470
1034674309 wants to merge 21 commits into
Conversation
Add independent Sentry traces for AI Think and Archive, with a real http.client span around browser fetch. Keep attempt_id as an internal correlation ID only. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
AI Interaction Sentry tracing — review
Solid, carefully-commented change. The sync/async boundary is handled consistently (synchronous root registration for the Go WASM stack, Sentry work deferred via setTimeout(0)), the JS bridge is defensively wrapped so Sentry/JS failures can never break the AI request, and the Go side has thorough "finish once" test coverage. No blocking issues.
The most substantive theme is what AI data reaches Sentry and at what volume — see the inline notes on the .env/env.ts defaults and the exception payload. The rest are minor maintainability/clarity notes.
Privacy / data capture (worth a decision before rollout)
- On a Think
failure, the exception payload forwards the raw user prompt (user_msg),command_name, andcommand_args(model-generated, often echoing user content) plus the backend error body (as the exceptionmessage) into Sentry viascope.setContext('ai', ...). AI prompts are free-form user input and may contain PII/secrets. There is no scrubbing at capture time or inbeforeSend(spx-gui/src/setup/sentry.ts), which today only filters error types. Consider scrubbing/omittinguser_msg/command_args, or gating behind consent. VITE_SENTRY_AI_SAMPLE_RATEdefaults to1(100% capture) in both.envfiles and theenv.tsfallback. Combined with the body/prompt capture above, this maximizes sensitive-data volume. The implementation doc itself lists production sampling tuning as "not done" (slice 5) — worth lowering before rollout.
Robustness notes
- The
thinks/archivesMaps hold openforceTransactionspans and rely entirely on the Godefer endThink/defer endArchiveto end and delete them. If the iframe is torn down mid-interaction (or a Go path returns without the defer running), those entries and their open transactions leak, since there is no TTL/teardown hook tied to the iframe lifecycle. A defensive sweep or a comment documenting the reliance would help. The globalattemptsMap has the same fragility — its only cleanup routes arefetchAIsettling or the root drain, so an attempt registered without a matchingfetchAIand without the root ending would leak permanently. mergeAbortSignalsadds{ once: true }abort listeners to each source signal; on the normal success path neither aborts, so the listeners live until the source signals are GC'd — avoidable churn on the hot AI-request path. Separately, if all input signals areundefinedit returns a never-abortable signal; todayfetchAIalways passes at least one, so this is latent.
Docs / minor
SENTRY_IMPLEMENTATION.mdliststransport.goin the modified-files list, but that file has no hunk in this PR — the new error types live in the newhttperr.go(which is only in the "new" list). Recommend swapping them.classify.goclassifyCommandStopFinishhas areason: "other"default branch that is unreachable given the current caller (callCommandHandleronly returns*invalidArgumentsError/*handlerPanicErrorinerr). The plan docs state there's nootherfallback; either drop it or comment it as an unreachable safety net.
| timeoutCtx, cancel := stdContext.WithTimeout(thinkCtx, transportTimeout) | ||
| attemptCtx := startAttempt(timeoutCtx) | ||
| attemptCount++ | ||
| lastAttempt = attempt |
There was a problem hiding this comment.
lastAttempt is declared once outside the turn loop and only assigned here, after a successful rateGate.Wait. If a later turn breaks out of the retry loop because the rate-limit Wait fails on its first iteration (the break above), lastAttempt is never updated for that turn and retains the value from a prior turn. It is then passed into withException(..., lastAttempt, ...) and surfaces as the attempt tag on the Sentry exception. Diagnostic-only, but the reported attempt can be misleading — consider resetting it per turn or deriving it from attemptCount.
|
|
||
| # Sampling rate for independent AI traces (ai.think and later ai.* roots). | ||
| # Kept separate from pageload so Think is not all-or-none with the page. | ||
| VITE_SENTRY_AI_SAMPLE_RATE="1" |
There was a problem hiding this comment.
AI trace sample rate defaults to 1 (100% capture) here and in account/.env and the env.ts fallbacks. Combined with the prompt/command-arg capture in the exception payload (and backend request/response body capture per the design doc), this sends the maximum volume of potentially sensitive AI data to Sentry. The implementation doc flags production sampling tuning as not-yet-done — worth lowering before rollout.
| if (url.startsWith('data:')) return false | ||
| // AI requests create their own http.client under ai.think / ai.archive. | ||
| // API base URLs vary between deployments and may or may not include /api. | ||
| if (url.includes('/ai-interaction/turns') || url.includes('/ai-interaction/archives')) return false |
There was a problem hiding this comment.
While this file is being touched for AI tracing: the beforeSend hook (further down) filters only ignored exception types and does not scrub the new ai context (user_msg, command_args) or the backend error body carried in the exception message. Since AI prompts are free-form user input that may contain PII/secrets, beforeSend is the natural place to add allow-list scrubbing if the capture in the summary above is intentional.
| } | ||
|
|
||
| return { | ||
| startThink(info) { |
There was a problem hiding this comment.
startThink (and startArchive below) synchronously register a Map entry holding an open forceTransaction span. If WASM never calls the matching endThink/endArchive — e.g. the iframe is torn down mid-interaction — the entry and its open transaction are never ended or deleted; there is no TTL/teardown hook. Cleanup relies entirely on the Go-side defer endThink. Worth a comment documenting that reliance, or a defensive sweep tied to the iframe lifecycle.
| func classifyCommandStopFinish(err error) thinkFinish { | ||
| category, reason, ok := classifyCommandStop(err) | ||
| if !ok { | ||
| return thinkFinish{outcome: outcomeFailure, category: categoryRuntimeFailure, reason: reasonOther} |
There was a problem hiding this comment.
This reason: reasonOther default branch is unreachable given the current caller: callCommandHandler only returns *invalidArgumentsError or *handlerPanicError in its err result (regular handler failures go into result.ErrorMessage), so classifyCommandStop always returns ok == true. The plan docs state there's no other fallback on the frontend. Consider dropping the branch/constant or commenting it as an intentional unreachable safety net.
|
|
||
| 前端 `builder`: | ||
|
|
||
| - 改:`ProjectRunner.vue`、`tracing.ts`、`sentry.ts`、`env.ts`、`ai.go`、`command.go`、`transport.go`、`wasmtrans.go`、`ispx/ai.go` |
There was a problem hiding this comment.
transport.go is listed here as modified, but it has no hunk in this PR — it's unchanged. The new error types (QuotaExceededError, HTTPError, TimeoutError, ErrorFromHTTPResponse) were added in the new file httperr.go, which appears only in the "new" list. Recommend dropping transport.go from the modified list (or replacing it with httperr.go).
| return nil | ||
| } | ||
|
|
||
| func setSentryBridge(this js.Value, args []js.Value) any { |
There was a problem hiding this comment.
这里实现不太好,建议有一个 messageReplier,在 init 是,初始化一个 noop 的 replier,这个接口(setSentryBridge)可以设置一个 真实的 replier,这个 replier是 ispx 的,但 ai 包,甚至 spx 后面的 ispx 都能够使用,但不是直接使用,而是实现了 Replier 的 interface ,比如 SendMessage,message 最好遵循 jsonrpc,可以通过 通过 ai.SetReplier , 和 ispx.Init 在 ispxInit 就把这个结构体传过去,这个结构体大概长这样
type MessageReplier struct {
messageReplier js.Value
}
func (r * MessageReplier) SendMessage(m jsonrpc2.Message) (err error) {
rawMessage, err := json.Marshal(m)
if err != nil {
return err
}
// Catch potential panics during JavaScript execution.
defer func() {
if r := recover(); r != nil {
if jsErr, ok := r.(js.Error); ok {
err = fmt.Errorf("client error: %w", jsErr)
} else {
err = fmt.Errorf("client panic: %v", r)
}
}
}()
message := js.Global().Get("JSON").Call("parse", string(rawMessage))
s.messageReplier.Invoke(message)
return nil
}
var replier *MessageReplier = defaultReplier
func ispxInit() error {
...
if err := initAI(ixgoCtx, replier); err != nil {
return fmt.Errorf("failed to init ai: %w", err)
}
return ispx.Init(ixgoCtx, replier)
}关于 ai 包的指标收集,通过 replier 回复:
There was a problem hiding this comment.
这样理解对吗:
JSON-RPC 负责埋点、trace headers、cancel。
SentryTransport 负责获取 trace headers 和观察请求结果。
wasmtrans 仍负责 HTTP 请求、响应解析和网络取消。
AI 的 HTTP body 和 response 不经过 JSON-RPC。
| const runnerIframeWindowRef = ref<RunnerIframeWindow | null>(null) | ||
| let engineInitPromise: Promise<void> | null = null | ||
|
|
||
| async function readAIResponse(response: Response): Promise<AIFetchResult> { |
There was a problem hiding this comment.
需要实现的是通用的接收 ispx replier 的函数,而不是专用的 AIResponse
There was a problem hiding this comment.
另外需要考虑cancel 的实现,用于清理ispx 内部记录耗时的记录,stopGame 后,还是会发replier
There was a problem hiding this comment.
HTTP 链路(原来的 wasmtrans):
wasmtrans → fetch → backend → Response → wasmtrans
消息旁路(ispx replier):
AI / ispx → Replier(JSON-RPC) → ProjectRunner
这样理解对吗:
Replier 接收:
- 埋点开始、结束
- 错误分类
- session cancel
- trace context
不接收: - HTTP 请求
- HTTP Response
- AI 响应正文
| func (t *wasmTransport) fetchAndParse(ctx context.Context, path string, body []byte, result any) error { | ||
| headers := t.buildHeaders() | ||
|
|
||
| jsAbortController := js.Global().Get("AbortController").New() |
There was a problem hiding this comment.
这个文件就不需要改了,通过 SetDefaultTransport,把 transport 包装下就好了
There was a problem hiding this comment.
按照我最下面评论的思路看到话,为了保持前后端 trace 关联,可能还需要一个 通用的请求头注入入口,用于传入 Sentry-Trace 和 Baggage
|
我的理解是,JSON-RPC 只作为通用的埋点和控制通道,不代理 AI 的 HTTP 请求和响应。AI 请求仍由 wasmtrans 直接发出,SentryTransport 只在外层记录调用过程,并通过 Replier 把消息发送给父页面。(如果代理 HTTP 的话,那可能就要覆盖当前的 wasmtrans 文件了) |
| tracesSampleRate: Number.isNaN(sentryTracesSampleRate) ? 0.1 : sentryTracesSampleRate, | ||
| lspSampleRate: Number.isNaN(sentryLSPSampleRate) ? 0.1 : sentryLSPSampleRate | ||
| lspSampleRate: Number.isNaN(sentryLSPSampleRate) ? 0.1 : sentryLSPSampleRate, | ||
| aiSampleRate: Number.isNaN(sentryAISampleRate) ? 1 : sentryAISampleRate |
There was a problem hiding this comment.
不建议叫 aiSampleRate,可以叫 ispxSampleRate
| } | ||
|
|
||
| // buildHeaders creates request headers with proper authentication. | ||
| // buildHeaders creates request headers with proper authentication. The parent |
There was a problem hiding this comment.
这个文件不建议改,建议加一个 traceTransport,包装一下 defaultTransport,实现 Interact/Archive 接口即可,然后在后面 SetDefaultTransport 里使用 traceTransport
| // NewSentryTransport wraps t with the Sentry bookkeeping shared by Interact | ||
| // and Archive calls. A nil transport stays nil so SetDefaultTransport keeps its | ||
| // existing reset behavior. | ||
| func NewSentryTransport(t Transport) Transport { |
There was a problem hiding this comment.
叫 NewTraceTransport 吧,Sentry 只是 Trace 用到的中间件,在这里,我们只是需要我们构造 trace 数据给 web,web 那边基于这份 trace 数据,可以用 Sentry 页可以用其他的。所以这里是不需要理解 sentry 的
| @@ -0,0 +1,149 @@ | |||
| package ai | |||
There was a problem hiding this comment.
ai 包,怎么有这么多的改动,我理解 他只需要有 三个地方的改动:
- trace.go // 用于构造 trace 数据,这个trace 数据的构造,有一些接口注入,这些接口比如 startSpan,endSpan ,setExtra 等,这些是通用的
- trace_transport.go // 这个文件包装 defaultTransport
- 调用 trace.go 中的方法
| rateLimitWaitTimeout = 2 * time.Minute // Maximum wait time for rate limiting. | ||
| ) | ||
|
|
||
| thinkCtx := withTracer(ctx, tracerFromContext(ctx)) |
There was a problem hiding this comment.
为了实现 tracer 对原有代码入侵如此之大,是不可接收的
There was a problem hiding this comment.
原先在 Think 里用 withTracer / tracerFromContext 把 span 生命周期嵌进业务循环,重试、command、history 都会碰到 tracer。现在 Think 只在入口取出当时的 Transport 并沿用到本次 Interact/Archive,避免 runner 换 session 时进行中的对话换到新包装上。Tracing 改到 iSPX 侧:SetDefaultTransport 把 telemetryTransport 包在 wasmtrans 外面,每次真实 HTTP 在 wrapper 里 start/finish,ai.go 的循环不再创建或结束 span。
|
|
||
| func (t *traceTransport) Interact(ctx context.Context, req Request) (Response, error) { | ||
| tracer := tracerFromContext(ctx) | ||
| spanCtx, span, startErr := tracer.StartSpan(ctx, SpanInfo{ |
There was a problem hiding this comment.
这个 SpanInfo 也不能是专门给 ai 包用呀,goplus/spx 里的 ipsx 如果要记录tracer 就不能用了,先想清楚 telemetryMsg 长什么样,然后设计这里的 api
There was a problem hiding this comment.
原先 traceTransport 用 tools/ai 里的 SpanInfo 起 span,协议是 AI 专用的,goplus/spx 的 iSPX 没法复用。现在改成通用 JSON-RPC operation:WASM 在发 HTTP 前 telemetry/operation.start(Call),Web 返回 operationId 和 Sentry-Trace/Baggage;请求结束后 telemetry/operation.finish(Notification)带上状态。字段是 name / operation / 时间戳 / status / attributes / propagation,不出现 Think/Command。协议放在 tools/ispx/internal/telemetry,Sentry 只在 Web adapter 里映射;tools/ai 只提供 Transport 和 context 里透传不透明 header。和 LSP 事后 telemetry/event 不同,这里必须在 fetch 前拿到传播 header,才能把后端 http.server 接进同一条 trace。
Request-level Sentry tracing only needs headers and a finish callback. A page-injected hook is enough; JSON-RPC sessions and pending maps were extra machinery. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…tion # Conflicts: # tools/ai/ai.go # tools/ai/ai_test.go # tools/ai/command.go # tools/ai/wasmtrans/wasmtrans.go
Summary
http.clientroot for each AI Transport call (POST /ai-interaction/turnsandPOST /ai-interaction/archives).Sentry-Trace/Baggageand afinishcallback bound to that span.fetchinwasmtrans. Extra headers copy through context and cannot overwrite protected HTTP fields.VITE_SENTRY_ISPX_SAMPLE_RATE(default1) and exclude those two URLs from automatic fetch tracing.Each Transport retry is its own root. Rate-limit waits happen before the Transport call, so they are not part of the span.
Companion backend trace continuation, API body capture,
error.message, andrequest_idare in builder-backend#351.Test plan
http.clientrootSentry-Trace/Baggage, and the backend transaction uses the same trace IDRelated: #3387.