"Use when fixing zero-value display bugs, silent channel failures, or implementing data visibility safeguards. Triggers: zero-card bug, silent fetch failure, status.data_status, ChannelErrors, FetchResult.Fallback, MacroDataSnapshot all-zero"
Install
npx skillscat add kaecer68/atlas-go/atlas-data-visibility Install via the SkillsCat registry.
問題背景
Cross-market 頁面曾出現「八張卡片全部顯示 0」的嚴重 bug。根目因並非資料源本身為零,而是某些 channel(如 US indices / tech stocks)在背景 fetch 失敗後,系統靜默回傳了 zero-value 的 MacroDataSnapshot。前端因為沒有任何錯誤標記,直接把空字串當作正常資料渲染,導致用戶看到一片 0 值卻無從得知資料其實已經失效。
這個 bug 催生了四層漸進式資料可見性保障機制:從最底層的 gateway fetch 結果標記,一路穿透到前端 UI 的紅色錯誤徽章。每一層都負責把「資料可能有問題」這個訊息向上傳遞,不讓任何一層靜默吞掉錯誤。
四層機制
Layer 1: Gateway (internal/apigateway/)
Gateway 層在 FetchResult struct 中新增了 Stale、Fallback、LastError 三個欄位,用來標記這次 fetch 的品質狀態。
檔案: internal/apigateway/provider.go
type FetchResult struct {
Data []byte
Stale bool // true if returned from cache on circuit-breaker open
Fallback bool // true if using fallback provider
LastError string // last fetch error message
Timestamp time.Time
}檔案: internal/apigateway/gateway.go
當 circuit breaker 開啟、系統改走 stale cache 路徑時,gateway 會主動標記 Stale = true 並附上錯誤訊息:
// Stale cache path: circuit breaker is open
return FetchResult{
Data: cached.Data,
Stale: true,
LastError: fmt.Sprintf("circuit breaker open for %s: %v", channelID, err),
Timestamp: time.Now(),
}, nil關鍵設計: Gateway 不判斷「這筆資料還能不能用」,它只做一件事:誠實報告「我這次是怎麼拿到這筆資料的」。上層自行決定如何處理 stale/fallback 資料。
Layer 2: Adapter (internal/monitoring/gateway_adapter.go)
Adapter 層負責把 gateway 的 per-fetch 錯誤彙整成 per-channel 錯誤地圖,讓 service 層可以一次看到「哪些 channel 掛了」。
檔案: internal/monitoring/gateway_adapter.go
type macroDataGatewayAdapter struct {
gateway apigateway.Gateway
lastErrors map[string]string // channelID -> error message
mu sync.RWMutex
}
func (a *macroDataGatewayAdapter) ChannelErrors() map[string]string {
a.mu.RLock()
defer a.mu.RUnlock()
errs := make(map[string]string, len(a.lastErrors))
for k, v := range a.lastErrors {
errs[k] = v
}
return errs
}在 FetchSnapshot 中,每個 channel 獨立 fetch,失敗時記錄錯誤但不中斷其他 channel:
func (a *macroDataGatewayAdapter) FetchSnapshot(ctx context.Context) (*MacroDataSnapshot, error) {
snapshot := &MacroDataSnapshot{}
// Clear previous errors
a.mu.Lock()
a.lastErrors = make(map[string]string)
a.mu.Unlock()
// Fetch each channel independently
for _, channel := range a.channels {
result, err := a.gateway.Fetch(ctx, channel)
if err != nil {
a.mu.Lock()
a.lastErrors[channel] = err.Error()
a.mu.Unlock()
continue // Don't fail the entire snapshot
}
if result.Stale {
a.mu.Lock()
a.lastErrors[channel] = "stale: " + result.LastError
a.mu.Unlock()
}
// Merge data into snapshot...
}
return snapshot, nil
}關鍵設計: Adapter 是「錯誤收集器」,不是「錯誤處理器」。它把分散的 channel 錯誤彙整成一份地圖,但不決定這些錯誤是否嚴重到需要降級整個頁面。
Layer 3: Service (internal/monitoring/service/crossmarket.go)
Service 層負責「業務語義判斷」:根據 adapter 提供的錯誤地圖和 snapshot 內容,決定整體資料狀態是 ok / degraded / stale。
檔案: internal/monitoring/service/crossmarket.go
type CrossMarketStatus struct {
DataStatus string `json:"data_status"` // "ok" | "degraded" | "stale"
FailedChannels []string `json:"failed_channels"` // which channels failed
// ... other fields
}GetStatus() 的核心邏輯:當發現所有 8 個 US index/tech 欄位都是 zero-symbol 時,標記為 degraded:
func (s *CrossMarketService) GetStatus(ctx context.Context) (*CrossMarketStatus, error) {
snapshot, err := s.adapter.FetchSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("fetch snapshot: %w", err)
}
status := &CrossMarketStatus{
DataStatus: "ok",
}
// Check for all-zero condition (the bug trigger)
if s.isAllZero(snapshot) {
status.DataStatus = "degraded"
status.FailedChannels = s.extractFailedChannels(snapshot)
}
// Also incorporate adapter-level channel errors
for channel, errMsg := range s.adapter.ChannelErrors() {
if errMsg != "" {
status.FailedChannels = append(status.FailedChannels, channel)
}
}
return status, nil
}
func (s *CrossMarketService) isAllZero(snapshot *MacroDataSnapshot) bool {
// Check all 8 US index/tech fields
fields := []string{
snapshot.Nasdaq.Symbol,
snapshot.SP500.Symbol,
snapshot.Dow.Symbol,
snapshot.VIX.Symbol,
snapshot.NVDA.Symbol,
snapshot.TSM.Symbol,
snapshot.AAPL.Symbol,
snapshot.MSFT.Symbol,
}
for _, sym := range fields {
if sym != "" {
return false
}
}
return true
}Cache 層也會 co-cache 這個 degraded 狀態:
func (s *CrossMarketService) getCachedSnapshot(ctx context.Context) (*CachedSnapshot, error) {
// Cache key includes status so degraded state is cached too
cached := s.cache.Get("crossmarket_snapshot")
if cached != nil {
return cached.(*CachedSnapshot), nil
}
status, _ := s.GetStatus(ctx)
snapshot := &CachedSnapshot{
Status: status,
Timestamp: time.Now(),
}
s.cache.Set("crossmarket_snapshot", snapshot, cache.DefaultExpiration)
return snapshot, nil
}關鍵設計: Service 層是「業務判官」。它根據領域知識(8 個 US 欄位全零 = 資料異常)決定整體狀態,並把這個判斷結果寫入 API response,讓前端不需要自己猜。
Wave 9 擴展:ChannelHealthSynthesizer 與 IngestionLagMonitor
同樣的 L2 ChannelErrors() API 在 Wave 9 被 ChannelHealthSynthesizer 消費,轉為事件驅動的個別通道健康通知:
| 元件 | 檔案 | 輸入 | 輸出 |
|---|---|---|---|
ChannelHealthSynthesizer |
internal/monitoring/service/channel_health_synthesizer.go |
ChannelHealthProvider.ChannelErrors() |
EventChannelIndividualHealth |
IngestionLagMonitor |
internal/monitoring/service/ingestion_lag_monitor.go |
IngestionLagProvider.P99LatencySeconds()(生產實作為 ChannelHealthIngestionLagProvider,底層讀取 UnifiedHealthStore.ChannelLatencyMs) |
EventIngestionLagSpike |
兩者都遵循「底層誠實報告、上層業務判斷、事件驅動暴露」的資料可見性模式,與 CrossMarketService 共享同一套 L1/L2 基礎設施。詳見 internal/monitoring/wave9_runtime.go。
Layer 4: Frontend (shared_web/static/js/pages/crossmarket.js)
Frontend 層負責「用戶可見的錯誤表達」。當收到 data_status === "degraded" 或個別欄位 failed: true 時,必須讓用戶一眼就知道資料有問題,而不是默默顯示 0。
檔案: shared_web/static/js/pages/crossmarket.js
getField() 在欄位缺少 symbol 時回傳 failed: true:
function getField(snapshot, fieldName) {
const field = snapshot[fieldName];
if (!field || field.symbol === "" || field.symbol === undefined) {
return {
value: "—",
change: "—",
failed: true,
errorMessage: field?._error || "資料獲取失敗"
};
}
return {
value: field.value,
change: field.change_pct,
failed: false
};
}kpiCard() 在 failed=true 時顯示紅色錯誤徽章:
function kpiCard(title, field, options = {}) {
const card = document.createElement('div');
card.className = 'kpi-card';
if (field.failed) {
card.classList.add('kpi-card--error');
card.innerHTML = `
<div class="kpi-card__header">
<span class="kpi-card__title">${title}</span>
<span class="badge badge--error">資料獲取失敗</span>
</div>
<div class="kpi-card__body">
<span class="kpi-card__value kpi-card__value--error">—</span>
${options.showSparkline ? '<div class="sparkline sparkline--error"></div>' : ''}
</div>
`;
} else {
// Normal rendering...
}
return card;
}頁面頂部的 degraded banner:
function renderDegradedBanner(status) {
if (status.data_status === "degraded") {
const banner = document.createElement('div');
banner.className = 'error-banner error-banner--warning';
banner.innerHTML = `
<span class="error-banner__icon">⚠</span>
<span class="error-banner__text">
部分資料無法即時更新(${status.failed_channels.join(', ')})
顯示數值可能為過期資料或預設值
</span>
`;
document.querySelector('.page-header').prepend(banner);
}
}關鍵設計: Frontend 不判斷「資料有沒有問題」,它只判斷「後端有沒有告訴我這筆資料有問題」。所有的錯誤 UI 都來自後端的明確標記,不是前端自己對數值做 heuristic。
實作檢查清單
Layer 1: Gateway
-
FetchResultstruct 有Stale、Fallback、LastError欄位 - Stale cache path(circuit breaker open)會設定
Stale = true - Fallback provider path會設定
Fallback = true - 單元測試驗證:circuit breaker open 時回傳
Stale=true
Layer 2: Adapter
- Adapter 有
lastErrors map[string]string欄位 - 暴露
ChannelErrors() map[string]string方法 -
FetchSnapshot對每個 channel 獨立 fetch,失敗時記錄但不中斷 - 單元測試驗證:部分 channel 失敗時,其他 channel 仍正常回傳
Layer 3: Service
-
CrossMarketStatus有DataStatus string和FailedChannels []string -
GetStatus()能檢測 all-zero 條件並標記data_status="degraded" - Cache co-caches 降級狀態(不要讓 cache 蓋掉 degraded 標記)
- 單元測試驗證:all-zero snapshot →
data_status="degraded"
Layer 4: Frontend
-
getField()在symbol === ""時回傳failed: true -
kpiCard()在failed=true時顯示紅色錯誤徽章(不是 0) - 頁面頂部有 degraded banner 當
data_status === "degraded" - 整合測試驗證:degraded API response → 紅色徽章 + banner 顯示
測試場景
場景一:單一 channel 失敗
條件: US indices channel 回傳 error,但 tech stocks channel 正常
預期結果:
- Layer 1:
FetchResult.LastError有錯誤訊息 - Layer 2:
ChannelErrors()["us-indices"]有錯誤訊息 - Layer 3:
DataStatus="degraded",FailedChannels=["us-indices"] - Layer 4: US indices 卡片顯示紅色「資料獲取失敗」,tech stocks 卡片正常顯示
- PASS: 部分資料正常、部分顯示錯誤,不是全部變 0
場景二:全部 channel 失敗(all-zero bug)
條件: 所有 8 個 US index/tech channel 都回傳 error 或空 symbol
預期結果:
- Layer 1: 每個
FetchResult都有LastError - Layer 2:
ChannelErrors()有 8 個 channel 的錯誤 - Layer 3:
isAllZero()回傳 true,DataStatus="degraded" - Layer 4: 所有卡片顯示紅色「資料獲取失敗」,頁面頂部有 degraded banner
- PASS: 不是顯示 8 個 0,而是清楚告知用戶「資料獲取失敗」
場景三:Stale cache 路徑
條件: Circuit breaker 開啟,gateway 回傳 stale cache 資料
預期結果:
- Layer 1:
FetchResult.Stale=true,LastError="circuit breaker open..." - Layer 2:
ChannelErrors()[channel]= "stale: circuit breaker open..." - Layer 3: 若 stale 導致 all-zero,
DataStatus="degraded" - Layer 4: 卡片顯示「資料獲取失敗」或「資料可能過期」(依設計而定)
- PASS: 用戶知道看到的是過期資料,不是即時資料
場景四:快速恢復
條件: Channel 曾經失敗(被標記為 degraded),但在下次 fetch 時恢復正常
預期結果:
- Layer 1:
FetchResult.Stale=false,LastError="" - Layer 2:
ChannelErrors()[channel]= ""(被清除) - Layer 3:
DataStatus從 "degraded" 變回 "ok" - Layer 4: 紅色徽章消失,卡片顯示正常數值
- PASS: 錯誤狀態能自動恢復,不需要手動清除
反模式
反模式一:靜默吞掉 channel 錯誤
// BAD: 看到 err 就直接 continue,不記錄、不上報
for _, channel := range channels {
result, err := gateway.Fetch(ctx, channel)
if err != nil {
continue // Silent failure — no one knows this channel died
}
// ...
}後果: 前端顯示 0 或舊資料,用戶和開發者都不知道某些 channel 已經壞了。
反模式二:在 gateway 層決定「資料還能不能用」
// BAD: Gateway 自己決定 stale 資料要不要回傳 error
if result.Stale {
return nil, fmt.Errorf("data is stale, refusing to return")
}後果: Gateway 應該保持「誠實報告」的角色,不應該替上層做決策。上層(service)才知道「stale 到什麼程度還能接受」。
反模式三:前端自己做 heuristic 判斷資料是否異常
// BAD: 前端看到 value === 0 就猜「這可能是錯誤」
if (field.value === 0) {
showErrorBadge(); // 但 0 可能是正常值!
}後果: 假陽性爆炸。 legitimate 的 0 值(如 VIX=0 某天的確發生過)會被誤標為錯誤。前端應該完全信任後端的 failed / data_status 標記。
反模式四:Service 層不檢查 all-zero 就直接標記 ok
// BAD: 不檢查 snapshot 內容,只看有沒有 error
func GetStatus() {
if err != nil {
return "degraded"
}
return "ok" // 但 snapshot 可能全零!
}後果: Adapter 沒有回傳 error(因為每個 channel 都「成功」fetch 到空資料),但 snapshot 全零。Service 必須檢查業務語義層的異常,不能只看 error 有沒有 nil。
反模式五:Cache 蓋掉 degraded 狀態
// BAD: Cache 只存 snapshot,不存 status
cache.Set("snapshot", snapshot.Data) // status 被丟掉了後果: 第一次 fetch 標記為 degraded 後,後續從 cache 讀到的資料永遠是 ok(因為 status 沒被 cache)。Cache 必須 co-cache degraded 狀態。
相關檔案
| 檔案 | 層級 | 用途 |
|---|---|---|
internal/apigateway/provider.go |
Layer 1 | FetchResult struct 定義 |
internal/apigateway/gateway.go |
Layer 1 | Stale cache path 標記邏輯 |
internal/apigateway/health.go |
Layer 1/2 | UnifiedHealthStore 與 ChannelLatencyMs(Wave 9) |
internal/monitoring/gateway_adapter.go |
Layer 2 | ChannelErrors() 與 per-channel 錯誤收集 |
internal/monitoring/service/crossmarket.go |
Layer 3 | CrossMarketStatus 與 isAllZero() 檢測 |
internal/monitoring/service/channel_health_synthesizer.go |
Wave 9 | 將 ChannelErrors() 轉為 EventChannelIndividualHealth |
internal/monitoring/service/ingestion_lag_provider.go |
Wave 9 | ChannelHealthIngestionLagProvider 生產實作 |
internal/monitoring/wave9_runtime.go |
Wave 9 | 5 個偵測器協調器 |
shared_web/static/js/pages/crossmarket.js |
Layer 4 | getField()、kpiCard()、degraded banner |
shared_web/static/css/components/error-banner.css |
Layer 4 | 錯誤 banner 樣式 |
shared_web/static/css/components/badge.css |
Layer 4 | 紅色錯誤徽章樣式 |
技能版本: 1.1
最後更新: 2026-06-25
適用對象: Atlas-Go AI Agent