ops_upstream_context.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. package service
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  9. "github.com/gin-gonic/gin"
  10. "github.com/tidwall/gjson"
  11. "github.com/tidwall/sjson"
  12. )
  13. // Gin context keys used by Ops error logger for capturing upstream error details.
  14. // These keys are set by gateway services and consumed by handler/ops_error_logger.go.
  15. const (
  16. OpsUpstreamStatusCodeKey = "ops_upstream_status_code"
  17. OpsUpstreamErrorMessageKey = "ops_upstream_error_message"
  18. OpsUpstreamErrorDetailKey = "ops_upstream_error_detail"
  19. OpsUpstreamErrorsKey = "ops_upstream_errors"
  20. OpsUpstreamModelKey = "ops_upstream_model"
  21. // Optional stage latencies (milliseconds) for troubleshooting and alerting.
  22. OpsAuthLatencyMsKey = "ops_auth_latency_ms"
  23. OpsRoutingLatencyMsKey = "ops_routing_latency_ms"
  24. OpsUpstreamLatencyMsKey = "ops_upstream_latency_ms"
  25. OpsResponseLatencyMsKey = "ops_response_latency_ms"
  26. OpsTimeToFirstTokenMsKey = "ops_time_to_first_token_ms"
  27. // OpenAI WS 关键观测字段
  28. OpsOpenAIWSQueueWaitMsKey = "ops_openai_ws_queue_wait_ms"
  29. OpsOpenAIWSConnPickMsKey = "ops_openai_ws_conn_pick_ms"
  30. OpsOpenAIWSConnReusedKey = "ops_openai_ws_conn_reused"
  31. OpsOpenAIWSConnIDKey = "ops_openai_ws_conn_id"
  32. // OpsSkipPassthroughKey 由 applyErrorPassthroughRule 在命中 skip_monitoring=true 的规则时设置。
  33. // ops_error_logger 中间件检查此 key,为 true 时跳过错误记录。
  34. OpsSkipPassthroughKey = "ops_skip_passthrough"
  35. // OpsStreamErrorKey 保存 handleStreamingAwareError 在「响应已固化为 HTTP 200 的 SSE 流」
  36. // 上就地(in-band)补发错误帧时记录的 OpsStreamError。因为 wire 状态码停留在 200,
  37. // ops_error_logger 的 status>=400 采集路径永远不会触发,这类流内失败
  38. //(例如等待并发槽位超时后回退的限流、Wait 后二次计费校验失败)本会在错误看板里隐形。
  39. OpsStreamErrorKey = "ops_stream_error"
  40. OpsStreamErrorsKey = "ops_stream_errors"
  41. OpsStreamTurnKey = "ops_stream_turn"
  42. // Client-side configuration denials should remain visible in ops_error_logs,
  43. // but should be excluded from SLA/error-rate calculations.
  44. // ResponseCommittedKey 由 handleErrorResponse 系列函数在写完 HTTP 错误响应后设置。
  45. // ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。
  46. ResponseCommittedKey = "response_committed"
  47. OpsClientBusinessLimitedKey = "ops_client_business_limited"
  48. OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason"
  49. OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction"
  50. OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable"
  51. OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned"
  52. OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate"
  53. OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied"
  54. OpsClientBusinessLimitedReasonLocalModelConfiguration = "local_model_configuration"
  55. )
  56. func MarkResponseCommitted(c *gin.Context) { c.Set(ResponseCommittedKey, true) }
  57. func IsResponseCommitted(c *gin.Context) bool {
  58. v, ok := c.Get(ResponseCommittedKey)
  59. if !ok {
  60. return false
  61. }
  62. b, _ := v.(bool)
  63. return b
  64. }
  65. func SetOpsLatencyMs(c *gin.Context, key string, value int64) {
  66. if c == nil || strings.TrimSpace(key) == "" || value < 0 {
  67. return
  68. }
  69. c.Set(key, value)
  70. }
  71. // SetOpsUpstreamModel stores only the effective model slug for final Ops
  72. // attribution. Call it immediately before an upstream attempt is dispatched.
  73. func SetOpsUpstreamModel(c *gin.Context, model string) {
  74. if c == nil {
  75. return
  76. }
  77. if model = strings.TrimSpace(model); model != "" {
  78. c.Set(OpsUpstreamModelKey, model)
  79. }
  80. }
  81. // ClearOpsUpstreamModel invalidates attempt-scoped model attribution before a
  82. // newly selected account starts credential resolution or upstream dispatch.
  83. func ClearOpsUpstreamModel(c *gin.Context) {
  84. if c == nil {
  85. return
  86. }
  87. c.Set(OpsUpstreamModelKey, "")
  88. }
  89. func MarkOpsClientBusinessLimited(c *gin.Context, reason string) {
  90. if c == nil {
  91. return
  92. }
  93. c.Set(OpsClientBusinessLimitedKey, true)
  94. if reason = strings.TrimSpace(reason); reason != "" {
  95. c.Set(OpsClientBusinessLimitedReasonKey, reason)
  96. }
  97. }
  98. func HasOpsClientBusinessLimited(c *gin.Context) bool {
  99. if c == nil {
  100. return false
  101. }
  102. v, ok := c.Get(OpsClientBusinessLimitedKey)
  103. if !ok {
  104. return false
  105. }
  106. marked, _ := v.(bool)
  107. return marked
  108. }
  109. func OpsClientBusinessLimitedReason(c *gin.Context) string {
  110. if c == nil {
  111. return ""
  112. }
  113. v, ok := c.Get(OpsClientBusinessLimitedReasonKey)
  114. if !ok {
  115. return ""
  116. }
  117. reason, _ := v.(string)
  118. return strings.TrimSpace(reason)
  119. }
  120. // OpsStreamError 描述网关在「响应状态已固化为 200」之后(keepalive ping 或部分数据
  121. // 已 flush)就地以 SSE error 帧形式返回的错误。由于 HTTP 状态码停留在 200,
  122. // 而 ops_error_logger 以 status>=400 为采集触发条件,这类流内失败
  123. // (并发限流回退、Wait 后二次计费校验失败、流开始后才无可用账号等)本会在错误看板里
  124. // 完全隐形。handler.handleStreamingAwareError 负责标记,ops_error_logger 中间件在
  125. // status<400 分支消费它并补记一条错误日志。
  126. type OpsStreamError struct {
  127. // ErrType 是写入 SSE 帧的对客错误类型(如 rate_limit_error / upstream_error / api_error)。
  128. ErrType string
  129. // Code 是可选的稳定错误分类;用于既保留通用 OpenAI error.type,又向客户端和 Ops
  130. // 暴露可编程判断的细分类(如 upstream_http2_stream_error)。
  131. Code string
  132. // Message 是写入 SSE 帧的对客错误消息。
  133. Message string
  134. // IntendedStatus 是流若未固化本应返回的 HTTP 状态码(如并发限流的 429)。
  135. // 默认仅用于错误分级;CountTowardsSLA=true 时也作为 Ops 的逻辑状态码。
  136. IntendedStatus int
  137. // CountTowardsSLA 表示虽然 wire 状态已固化为 200,请求在应用语义上仍然失败,
  138. // Ops 应使用 IntendedStatus 计入错误率/SLA。
  139. CountTowardsSLA bool
  140. // Turn identifies a WebSocket turn. HTTP/SSE requests leave it at zero.
  141. Turn int
  142. // SkipMonitoring snapshots the rule decision for this visible failure.
  143. SkipMonitoring bool
  144. AccountID int64
  145. UpstreamModel string
  146. UpstreamStatus int
  147. UpstreamMessage string
  148. UpstreamDetail string
  149. UpstreamErrors []*OpsUpstreamErrorEvent
  150. }
  151. const maxOpsStreamErrorsPerRequest = 64
  152. // BeginOpsStreamTurn scopes first-wins deduplication to one WebSocket turn.
  153. func BeginOpsStreamTurn(c *gin.Context, turn int) {
  154. if c == nil || turn <= 0 {
  155. return
  156. }
  157. c.Set(OpsStreamTurnKey, turn)
  158. // Rule and attempt state is turn-scoped on a long-lived WS connection.
  159. c.Set(OpsSkipPassthroughKey, false)
  160. c.Set(OpsUpstreamErrorsKey, []*OpsUpstreamErrorEvent{})
  161. c.Set(OpsUpstreamStatusCodeKey, 0)
  162. c.Set(OpsUpstreamErrorMessageKey, "")
  163. c.Set(OpsUpstreamErrorDetailKey, "")
  164. }
  165. // MarkOpsStreamError 记录一次就地 SSE 错误,供 ops 日志采集。
  166. // 采用「首个标记生效」策略:同一请求若先后补发多帧(如上游透传错误后又追加通用兜底帧),
  167. // 保留最先记录的根因错误,而不是被后续的 "Upstream request failed" 覆盖。
  168. func MarkOpsStreamError(c *gin.Context, errType, message string, intendedStatus int) {
  169. markOpsStreamError(c, OpsStreamError{
  170. ErrType: errType,
  171. Message: message,
  172. IntendedStatus: intendedStatus,
  173. })
  174. }
  175. // MarkOpsStreamFailure records an in-band stream error that represents a failed
  176. // request and therefore must count towards Ops error rate/SLA despite HTTP 200
  177. // already being committed on the wire.
  178. func MarkOpsStreamFailure(c *gin.Context, errType, code, message string, intendedStatus int) {
  179. markOpsStreamError(c, OpsStreamError{
  180. ErrType: errType,
  181. Code: code,
  182. Message: message,
  183. IntendedStatus: intendedStatus,
  184. CountTowardsSLA: true,
  185. })
  186. }
  187. func markOpsStreamError(c *gin.Context, streamErr OpsStreamError) {
  188. if c == nil {
  189. return
  190. }
  191. streamErr.ErrType = strings.TrimSpace(streamErr.ErrType)
  192. streamErr.Code = strings.TrimSpace(streamErr.Code)
  193. streamErr.Message = strings.TrimSpace(streamErr.Message)
  194. streamErr.SkipMonitoring = currentOpsFailureSkipMonitoring(c)
  195. snapshotOpsStreamErrorContext(c, &streamErr)
  196. if GetOpenAIClientTransport(c) == OpenAIClientTransportWS {
  197. if value, ok := c.Get(OpsStreamTurnKey); ok {
  198. streamErr.Turn, _ = value.(int)
  199. }
  200. var errorsForRequest []OpsStreamError
  201. if value, ok := c.Get(OpsStreamErrorsKey); ok {
  202. errorsForRequest, _ = value.([]OpsStreamError)
  203. }
  204. if len(errorsForRequest) > 0 && errorsForRequest[len(errorsForRequest)-1].Turn == streamErr.Turn {
  205. return
  206. }
  207. errorsForRequest = append(errorsForRequest, streamErr)
  208. if len(errorsForRequest) > maxOpsStreamErrorsPerRequest {
  209. errorsForRequest = append([]OpsStreamError(nil), errorsForRequest[len(errorsForRequest)-maxOpsStreamErrorsPerRequest:]...)
  210. }
  211. c.Set(OpsStreamErrorsKey, errorsForRequest)
  212. c.Set(OpsStreamErrorKey, streamErr)
  213. return
  214. }
  215. if _, exists := c.Get(OpsStreamErrorKey); exists {
  216. return
  217. }
  218. c.Set(OpsStreamErrorKey, streamErr)
  219. }
  220. func snapshotOpsStreamErrorContext(c *gin.Context, streamErr *OpsStreamError) {
  221. if c == nil || streamErr == nil {
  222. return
  223. }
  224. if c.Request != nil {
  225. if accountID, ok := c.Request.Context().Value(ctxkey.AccountID).(int64); ok && accountID > 0 {
  226. streamErr.AccountID = accountID
  227. }
  228. }
  229. if value, ok := c.Get(OpsUpstreamModelKey); ok {
  230. streamErr.UpstreamModel, _ = value.(string)
  231. streamErr.UpstreamModel = strings.TrimSpace(streamErr.UpstreamModel)
  232. }
  233. if value, ok := c.Get(OpsUpstreamStatusCodeKey); ok {
  234. switch status := value.(type) {
  235. case int:
  236. streamErr.UpstreamStatus = status
  237. case int64:
  238. streamErr.UpstreamStatus = int(status)
  239. }
  240. }
  241. if value, ok := c.Get(OpsUpstreamErrorMessageKey); ok {
  242. streamErr.UpstreamMessage, _ = value.(string)
  243. streamErr.UpstreamMessage = strings.TrimSpace(streamErr.UpstreamMessage)
  244. }
  245. if value, ok := c.Get(OpsUpstreamErrorDetailKey); ok {
  246. streamErr.UpstreamDetail, _ = value.(string)
  247. streamErr.UpstreamDetail = strings.TrimSpace(streamErr.UpstreamDetail)
  248. }
  249. if value, ok := c.Get(OpsUpstreamErrorsKey); ok {
  250. if events, ok := value.([]*OpsUpstreamErrorEvent); ok {
  251. streamErr.UpstreamErrors = make([]*OpsUpstreamErrorEvent, 0, len(events))
  252. for _, event := range events {
  253. if event == nil {
  254. streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, nil)
  255. continue
  256. }
  257. copyOfEvent := *event
  258. streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, &copyOfEvent)
  259. }
  260. }
  261. }
  262. }
  263. func currentOpsFailureSkipMonitoring(c *gin.Context) bool {
  264. if c == nil {
  265. return false
  266. }
  267. if value, ok := c.Get(OpsSkipPassthroughKey); ok {
  268. if skip, _ := value.(bool); skip {
  269. return true
  270. }
  271. }
  272. if value, ok := c.Get(OpsUpstreamErrorsKey); ok {
  273. if events, ok := value.([]*OpsUpstreamErrorEvent); ok {
  274. for i := len(events) - 1; i >= 0; i-- {
  275. if events[i] != nil {
  276. return events[i].SkipMonitoring
  277. }
  278. }
  279. }
  280. }
  281. return false
  282. }
  283. // GetOpsStreamError 返回本请求记录的就地 SSE 错误(若有)。
  284. func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) {
  285. if c == nil {
  286. return OpsStreamError{}, false
  287. }
  288. v, ok := c.Get(OpsStreamErrorKey)
  289. if !ok {
  290. return OpsStreamError{}, false
  291. }
  292. se, ok := v.(OpsStreamError)
  293. return se, ok
  294. }
  295. func GetOpsStreamErrors(c *gin.Context) []OpsStreamError {
  296. if c == nil {
  297. return nil
  298. }
  299. if value, ok := c.Get(OpsStreamErrorsKey); ok {
  300. if errorsForRequest, ok := value.([]OpsStreamError); ok && len(errorsForRequest) > 0 {
  301. return append([]OpsStreamError(nil), errorsForRequest...)
  302. }
  303. }
  304. if streamErr, ok := GetOpsStreamError(c); ok {
  305. return []OpsStreamError{streamErr}
  306. }
  307. return nil
  308. }
  309. // SetOpsUpstreamError is the exported wrapper for setOpsUpstreamError, used by
  310. // handler-layer code (e.g. failover-exhausted paths) that needs to record the
  311. // original upstream status code before mapping it to a client-facing code.
  312. func SetOpsUpstreamError(c *gin.Context, upstreamStatusCode int, upstreamMessage, upstreamDetail string) {
  313. setOpsUpstreamError(c, upstreamStatusCode, upstreamMessage, upstreamDetail)
  314. }
  315. func setOpsUpstreamError(c *gin.Context, upstreamStatusCode int, upstreamMessage, upstreamDetail string) {
  316. if c == nil {
  317. return
  318. }
  319. if upstreamStatusCode > 0 {
  320. c.Set(OpsUpstreamStatusCodeKey, upstreamStatusCode)
  321. }
  322. if msg := strings.TrimSpace(upstreamMessage); msg != "" {
  323. c.Set(OpsUpstreamErrorMessageKey, msg)
  324. }
  325. if detail := strings.TrimSpace(upstreamDetail); detail != "" {
  326. c.Set(OpsUpstreamErrorDetailKey, detail)
  327. }
  328. }
  329. // OpsUpstreamErrorEvent describes one upstream error attempt during a single gateway request.
  330. // It is stored in ops_error_logs.upstream_errors as a JSON array.
  331. type OpsUpstreamErrorEvent struct {
  332. AtUnixMs int64 `json:"at_unix_ms,omitempty"`
  333. // Passthrough 表示本次请求是否命中“原样透传(仅替换认证)”分支。
  334. // 该字段用于排障与灰度评估;存入 JSON,不涉及 DB schema 变更。
  335. Passthrough bool `json:"passthrough,omitempty"`
  336. // Context
  337. Platform string `json:"platform,omitempty"`
  338. AccountID int64 `json:"account_id,omitempty"`
  339. AccountName string `json:"account_name,omitempty"`
  340. // Proxy attribution is an immutable, credential-free snapshot of the route
  341. // used by this attempt. ProxyID is null for direct and unknown routes;
  342. // ProxyName distinguishes direct/no_proxy from unknown.
  343. // Never add proxy URLs or credentials to this event.
  344. ProxyID *int64 `json:"proxy_id"`
  345. ProxyName string `json:"proxy_name"`
  346. // DroppedEarlierAttempts is set on the oldest retained event when queue
  347. // bounds forced earlier attempts of the same request to be discarded.
  348. DroppedEarlierAttempts int `json:"dropped_earlier_attempts,omitempty"`
  349. // Outcome
  350. UpstreamStatusCode int `json:"upstream_status_code,omitempty"`
  351. UpstreamRequestID string `json:"upstream_request_id,omitempty"`
  352. // UpstreamURL is the actual upstream URL that was called (host + path, query/fragment stripped).
  353. // Helps debug 404/routing errors by showing which endpoint was targeted.
  354. UpstreamURL string `json:"upstream_url,omitempty"`
  355. // Best-effort upstream response capture (sanitized+trimmed).
  356. UpstreamResponseBody string `json:"upstream_response_body,omitempty"`
  357. // Kind: http_error | request_error | retry_exhausted | failover
  358. Kind string `json:"kind,omitempty"`
  359. // Stage/Scope/Reason distinguish credential acquisition from inference
  360. // without overloading upstream_status_code with a synthetic HTTP status.
  361. Stage string `json:"stage,omitempty"`
  362. Scope string `json:"scope,omitempty"`
  363. Reason string `json:"reason,omitempty"`
  364. Message string `json:"message,omitempty"`
  365. Detail string `json:"detail,omitempty"`
  366. // SkipMonitoring is request-local rule state. It is intentionally excluded
  367. // from persisted attempt JSON. The logger consults it only when this event is
  368. // the final client-visible failure; recovered attempts remain provider-health
  369. // telemetry and do not count as failed requests.
  370. SkipMonitoring bool `json:"-"`
  371. }
  372. const (
  373. opsProxyNameDirect = "direct/no_proxy"
  374. opsProxyNameUnknown = "unknown"
  375. // opsProxyNameUnnamed labels a managed proxy whose name is blank. The
  376. // proxies.name column is NOT NULL/non-empty, so this is a defensive value.
  377. opsProxyNameUnnamed = "proxy"
  378. )
  379. func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) {
  380. if c == nil {
  381. return
  382. }
  383. if ev.AtUnixMs <= 0 {
  384. ev.AtUnixMs = time.Now().UnixMilli()
  385. }
  386. ev.Platform = strings.TrimSpace(ev.Platform)
  387. normalizeOpsUpstreamProxyAttribution(&ev)
  388. ev.UpstreamRequestID = strings.TrimSpace(ev.UpstreamRequestID)
  389. ev.UpstreamResponseBody = strings.TrimSpace(ev.UpstreamResponseBody)
  390. ev.Kind = strings.TrimSpace(ev.Kind)
  391. ev.Stage = strings.TrimSpace(ev.Stage)
  392. ev.Scope = strings.TrimSpace(ev.Scope)
  393. ev.Reason = strings.TrimSpace(ev.Reason)
  394. ev.UpstreamURL = strings.TrimSpace(ev.UpstreamURL)
  395. ev.Message = strings.TrimSpace(ev.Message)
  396. ev.Detail = strings.TrimSpace(ev.Detail)
  397. if ev.Message != "" {
  398. ev.Message = sanitizeUpstreamErrorMessage(ev.Message)
  399. }
  400. var existing []*OpsUpstreamErrorEvent
  401. if v, ok := c.Get(OpsUpstreamErrorsKey); ok {
  402. if arr, ok := v.([]*OpsUpstreamErrorEvent); ok {
  403. existing = arr
  404. }
  405. }
  406. evCopy := ev
  407. existing = append(existing, &evCopy)
  408. c.Set(OpsUpstreamErrorsKey, existing)
  409. checkSkipMonitoringForUpstreamEvent(c, &evCopy)
  410. }
  411. // opsUpstreamProxyAttribution derives both attribution fields from one
  412. // decision so they can never disagree. The event is assembled at the failure
  413. // site; forwarding code builds managed proxy routes from Proxy only when the
  414. // binding ID is also set, so the same rule decides the label here:
  415. //
  416. // - nil account -> (nil, unknown)
  417. // - no binding or no hydrated Proxy -> (nil, direct/no_proxy)
  418. // - hydrated Proxy without durable ID -> (nil, unknown): the transport did
  419. // use that proxy, but nothing durable identifies it
  420. // - otherwise -> (Proxy.ID, Proxy.Name or "proxy")
  421. //
  422. // Invariant: proxy_id == null implies proxy_name is one of the two sentinels.
  423. func opsUpstreamProxyAttribution(account *Account) (*int64, string) {
  424. if account == nil {
  425. return nil, opsProxyNameUnknown
  426. }
  427. if account.ProxyID == nil || account.Proxy == nil {
  428. return nil, opsProxyNameDirect
  429. }
  430. if account.Proxy.ID <= 0 {
  431. return nil, opsProxyNameUnknown
  432. }
  433. proxyID := account.Proxy.ID
  434. name := strings.TrimSpace(account.Proxy.Name)
  435. if name == "" {
  436. name = opsProxyNameUnnamed
  437. }
  438. return &proxyID, name
  439. }
  440. func opsUpstreamProxyID(account *Account) *int64 {
  441. proxyID, _ := opsUpstreamProxyAttribution(account)
  442. return proxyID
  443. }
  444. func opsUpstreamProxyName(account *Account) string {
  445. _, name := opsUpstreamProxyAttribution(account)
  446. return name
  447. }
  448. // opsUpstreamWSProxyAttribution is the OpenAI WebSocket variant. The WS dialer
  449. // sets an explicit proxy client only when the account has a usable managed
  450. // proxy; otherwise coder/websocket falls back to http.DefaultClient, which
  451. // honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY. A missing managed proxy is therefore
  452. // unknown, not direct.
  453. func opsUpstreamWSProxyAttribution(account *Account) (*int64, string) {
  454. proxyID, name := opsUpstreamProxyAttribution(account)
  455. if proxyID == nil {
  456. return nil, opsProxyNameUnknown
  457. }
  458. return proxyID, name
  459. }
  460. func setUnknownOpsUpstreamProxy(ev *OpsUpstreamErrorEvent) {
  461. if ev == nil {
  462. return
  463. }
  464. ev.ProxyID = nil
  465. ev.ProxyName = opsProxyNameUnknown
  466. }
  467. // normalizeOpsUpstreamProxyAttribution makes legacy events explicit without
  468. // pretending that the account's current proxy is historical evidence.
  469. func normalizeOpsUpstreamProxyAttribution(ev *OpsUpstreamErrorEvent) {
  470. if ev == nil {
  471. return
  472. }
  473. if ev.ProxyID != nil && *ev.ProxyID <= 0 {
  474. // A non-positive ID never identifies a managed proxy.
  475. ev.ProxyID = nil
  476. }
  477. ev.ProxyName = strings.TrimSpace(ev.ProxyName)
  478. if ev.ProxyID != nil {
  479. if ev.ProxyName == "" {
  480. ev.ProxyName = opsProxyNameUnnamed
  481. }
  482. return
  483. }
  484. // Invariant: proxy_id == null implies a sentinel name. Any other name
  485. // without a durable ID is not historical evidence of a managed route.
  486. if ev.ProxyName != opsProxyNameDirect {
  487. setUnknownOpsUpstreamProxy(ev)
  488. }
  489. }
  490. // checkSkipMonitoringForUpstreamEvent snapshots whether this attempt matches a
  491. // skip_monitoring passthrough rule. The final failure decides whether the
  492. // request error is hidden; an intermediate recovered attempt cannot suppress a
  493. // later client-visible failure.
  494. func checkSkipMonitoringForUpstreamEvent(c *gin.Context, ev *OpsUpstreamErrorEvent) {
  495. if ev.UpstreamStatusCode == 0 {
  496. return
  497. }
  498. svc := getBoundErrorPassthroughService(c)
  499. if svc == nil {
  500. return
  501. }
  502. // Use the best available body representation for keyword matching.
  503. // Even when body is empty, MatchRule can still match rules that only
  504. // specify ErrorCodes (no Keywords), so we always call it.
  505. body := ev.Detail
  506. if body == "" {
  507. body = ev.Message
  508. }
  509. rule := svc.MatchRule(ev.Platform, ev.UpstreamStatusCode, []byte(body))
  510. if rule != nil && rule.SkipMonitoring {
  511. ev.SkipMonitoring = true
  512. }
  513. }
  514. func marshalOpsUpstreamErrors(events []*OpsUpstreamErrorEvent) *string {
  515. if len(events) == 0 {
  516. return nil
  517. }
  518. // Ensure we always store a valid JSON value.
  519. raw, err := json.Marshal(events)
  520. if err != nil || len(raw) == 0 {
  521. return nil
  522. }
  523. s := string(raw)
  524. return &s
  525. }
  526. func ParseOpsUpstreamErrors(raw string) ([]*OpsUpstreamErrorEvent, error) {
  527. raw = strings.TrimSpace(raw)
  528. if raw == "" {
  529. return []*OpsUpstreamErrorEvent{}, nil
  530. }
  531. var out []*OpsUpstreamErrorEvent
  532. if err := json.Unmarshal([]byte(raw), &out); err != nil {
  533. return nil, err
  534. }
  535. for _, ev := range out {
  536. normalizeOpsUpstreamProxyAttribution(ev)
  537. }
  538. return out, nil
  539. }
  540. // normalizeOpsUpstreamErrorsJSON materializes missing proxy attribution on
  541. // stored JSON for detail reads. It edits only the attribution keys of events
  542. // that lack them, so keys written by older struct versions and the original
  543. // key order survive; nothing is re-marshaled through the current struct.
  544. func normalizeOpsUpstreamErrorsJSON(raw string) (string, error) {
  545. raw = strings.TrimSpace(raw)
  546. if raw == "" {
  547. return raw, nil
  548. }
  549. if !gjson.Valid(raw) {
  550. return "", errors.New("upstream_errors is not valid JSON")
  551. }
  552. parsed := gjson.Parse(raw)
  553. if !parsed.IsArray() {
  554. return "", errors.New("upstream_errors is not a JSON array")
  555. }
  556. out := raw
  557. for i, ev := range parsed.Array() {
  558. if !ev.IsObject() {
  559. continue
  560. }
  561. prefix := strconv.Itoa(i) + "."
  562. proxyID := ev.Get("proxy_id")
  563. proxyName := strings.TrimSpace(ev.Get("proxy_name").String())
  564. hasValidID := proxyID.Exists() && proxyID.Type == gjson.Number && proxyID.Int() > 0
  565. var err error
  566. switch {
  567. case hasValidID:
  568. if proxyName == "" {
  569. out, err = sjson.Set(out, prefix+"proxy_name", opsProxyNameUnnamed)
  570. }
  571. case proxyName == opsProxyNameDirect:
  572. if !proxyID.Exists() || proxyID.Type != gjson.Null {
  573. out, err = sjson.Set(out, prefix+"proxy_id", nil)
  574. }
  575. default:
  576. if out, err = sjson.Set(out, prefix+"proxy_id", nil); err == nil {
  577. out, err = sjson.Set(out, prefix+"proxy_name", opsProxyNameUnknown)
  578. }
  579. }
  580. if err != nil {
  581. return "", err
  582. }
  583. }
  584. return out, nil
  585. }
  586. // safeUpstreamURL returns scheme + host + path from a URL, stripping query/fragment
  587. // to avoid leaking sensitive query parameters (e.g. OAuth tokens).
  588. func safeUpstreamURL(rawURL string) string {
  589. rawURL = strings.TrimSpace(rawURL)
  590. if rawURL == "" {
  591. return ""
  592. }
  593. if idx := strings.IndexByte(rawURL, '?'); idx >= 0 {
  594. rawURL = rawURL[:idx]
  595. }
  596. if idx := strings.IndexByte(rawURL, '#'); idx >= 0 {
  597. rawURL = rawURL[:idx]
  598. }
  599. return rawURL
  600. }