Skip to content

feat(ispx): add request-level AI tracing via a JS hook - #3470

Open
1034674309 wants to merge 21 commits into
goplus:devfrom
1034674309:feat/sentry-ai-interaction
Open

1034674309 wants to merge 21 commits into
goplus:devfrom
1034674309:feat/sentry-ai-interaction

Conversation

@1034674309

@1034674309 1034674309 commented Aug 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Add an independent browser http.client root for each AI Transport call (POST /ai-interaction/turns and POST /ai-interaction/archives).
  • iSPX asks the page for a Trace Hook instead of JSON-RPC. The hook returns Sentry-Trace / Baggage and a finish callback bound to that span.
  • Keep native fetch in wasmtrans. Extra headers copy through context and cannot overwrite protected HTTP fields.
  • Tracing is fail-open: a missing hook, install failure, or Sentry exception does not block the AI request.
  • Sample AI roots with VITE_SENTRY_ISPX_SAMPLE_RATE (default 1) and exclude those two URLs from automatic fetch tracing.
traceTransport
  -> JS Trace Hook
  -> page creates an independent Sentry span
  <- { propagationHeaders, finish }
  -> wasmtrans fetch with Sentry-Trace / Baggage
      -> backend http.server
          -> existing model http.client
  -> finish(ok | error | cancelled)

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, and request_id are in builder-backend#351.

Test plan

  • Interact and Archive each create a named independent browser http.client root
  • The HTTP request carries Sentry-Trace / Baggage, and the backend transaction uses the same trace ID
  • Success, error, and cancelled (stop / timeout) map to the matching span status
  • Concurrent Players and Transport retries each get their own root
  • Stop, rerun, unmount, iframe reload, game error, and exit clear unfinished spans
  • Missing hook / Sentry failure still lets the AI request go through

Related: #3387.

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>

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and command_args (model-generated, often echoing user content) plus the backend error body (as the exception message) into Sentry via scope.setContext('ai', ...). AI prompts are free-form user input and may contain PII/secrets. There is no scrubbing at capture time or in beforeSend (spx-gui/src/setup/sentry.ts), which today only filters error types. Consider scrubbing/omitting user_msg/command_args, or gating behind consent.
  • VITE_SENTRY_AI_SAMPLE_RATE defaults to 1 (100% capture) in both .env files and the env.ts fallback. 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/archives Maps hold open forceTransaction spans and rely entirely on the Go defer endThink/defer endArchive to 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 global attempts Map has the same fragility — its only cleanup routes are fetchAI settling or the root drain, so an attempt registered without a matching fetchAI and without the root ending would leak permanently.
  • mergeAbortSignals adds { 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 are undefined it returns a never-abortable signal; today fetchAI always passes at least one, so this is latent.

Docs / minor

  • SENTRY_IMPLEMENTATION.md lists transport.go in the modified-files list, but that file has no hunk in this PR — the new error types live in the new httperr.go (which is only in the "new" list). Recommend swapping them.
  • classify.go classifyCommandStopFinish has a reason: "other" default branch that is unreachable given the current caller (callCommandHandler only returns *invalidArgumentsError/*handlerPanicError in err). The plan docs state there's no other fallback; either drop it or comment it as an unreachable safety net.

Comment thread tools/ai/ai.go Outdated
timeoutCtx, cancel := stdContext.WithTimeout(thinkCtx, transportTimeout)
attemptCtx := startAttempt(timeoutCtx)
attemptCount++
lastAttempt = attempt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread spx-gui/src/apps/xbuilder/.env Outdated

# 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tools/ai/classify.go Outdated
func classifyCommandStopFinish(err error) thinkFinish {
category, reason, ok := classifyCommandStop(err)
if !ok {
return thinkFinish{outcome: outcomeFailure, category: categoryRuntimeFailure, reason: reasonOther}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tools/ai/SENTRY_IMPLEMENTATION.md Outdated

前端 `builder`:

- 改:`ProjectRunner.vue`、`tracing.ts`、`sentry.ts`、`env.ts`、`ai.go`、`command.go`、`transport.go`、`wasmtrans.go`、`ispx/ai.go`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread tools/ispx/ai.go Outdated
return nil
}

func setSentryBridge(this js.Value, args []js.Value) any {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里实现不太好,建议有一个 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 回复:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这样理解对吗:
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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

需要实现的是通用的接收 ispx replier 的函数,而不是专用的 AIResponse

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

另外需要考虑cancel 的实现,用于清理ispx 内部记录耗时的记录,stopGame 后,还是会发replier

@1034674309 1034674309 Aug 31, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个文件就不需要改了,通过 SetDefaultTransport,把 transport 包装下就好了

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按照我最下面评论的思路看到话,为了保持前后端 trace 关联,可能还需要一个 通用的请求头注入入口,用于传入 Sentry-Trace 和 Baggage

@1034674309

1034674309 commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

我的理解是,JSON-RPC 只作为通用的埋点和控制通道,不代理 AI 的 HTTP 请求和响应。AI 请求仍由 wasmtrans 直接发出,SentryTransport 只在外层记录调用过程,并通过 Replier 把消息发送给父页面。(如果代理 HTTP 的话,那可能就要覆盖当前的 wasmtrans 文件了)
由于还需要让前端和后端处于同一条 trace,父页面创建 span 后,可以通过 JSON-RPC 把 Sentry-Trace 和 Baggage 返回给 WASM,再由 wasmtrans 加到本次请求头中。这样不改变 wasmtrans 负责 HTTP 的定位,只需要提供一个通用的请求头注入入口,这个做法可以吗。

Comment thread spx-gui/src/apps/xbuilder/env.ts Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不建议叫 aiSampleRate,可以叫 ispxSampleRate

Comment thread tools/ai/wasmtrans/wasmtrans.go Outdated
}

// buildHeaders creates request headers with proper authentication.
// buildHeaders creates request headers with proper authentication. The parent

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个文件不建议改,建议加一个 traceTransport,包装一下 defaultTransport,实现 Interact/Archive 接口即可,然后在后面 SetDefaultTransport 里使用 traceTransport

Comment thread tools/ai/sentry_transport.go Outdated
// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

叫 NewTraceTransport 吧,Sentry 只是 Trace 用到的中间件,在这里,我们只是需要我们构造 trace 数据给 web,web 那边基于这份 trace 数据,可以用 Sentry 页可以用其他的。所以这里是不需要理解 sentry 的

Comment thread tools/ai/classify.go Outdated
@@ -0,0 +1,149 @@
package ai

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ai 包,怎么有这么多的改动,我理解 他只需要有 三个地方的改动:

  1. trace.go // 用于构造 trace 数据,这个trace 数据的构造,有一些接口注入,这些接口比如 startSpan,endSpan ,setExtra 等,这些是通用的
  2. trace_transport.go // 这个文件包装 defaultTransport
  3. 调用 trace.go 中的方法

Comment thread tools/ai/ai.go Outdated
rateLimitWaitTimeout = 2 * time.Minute // Maximum wait time for rate limiting.
)

thinkCtx := withTracer(ctx, tracerFromContext(ctx))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

为了实现 tracer 对原有代码入侵如此之大,是不可接收的

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

原先在 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。

Comment thread tools/ai/trace_transport.go Outdated

func (t *traceTransport) Interact(ctx context.Context, req Request) (Response, error) {
tracer := tracerFromContext(ctx)
spanCtx, span, startErr := tracer.StartSpan(ctx, SpanInfo{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个 SpanInfo 也不能是专门给 ai 包用呀,goplus/spx 里的 ipsx 如果要记录tracer 就不能用了,先想清楚 telemetryMsg 长什么样,然后设计这里的 api

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

原先 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。

@1034674309 1034674309 changed the title feat(ai): add Think, Archive, and client tracing feat(ispx): add request-level AI tracing over JSON-RPC Sep 6, 2026
田宇琨 and others added 2 commits September 10, 2026 11:35
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>
@1034674309 1034674309 changed the title feat(ispx): add request-level AI tracing over JSON-RPC feat(ispx): add request-level AI tracing via a JS hook Sep 10, 2026
田宇琨 added 2 commits September 17, 2026 12:09
…tion

# Conflicts:
#	tools/ai/ai.go
#	tools/ai/ai_test.go
#	tools/ai/command.go
#	tools/ai/wasmtrans/wasmtrans.go
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.

2 participants