前言
在 .NET Conf 2024 有機會分享了一段關於可觀測性實踐的歷程,收到不少朋友的回饋,說希望能看到更完整的文字版。這篇文章就是那次分享的延伸,我會用 dotnet/eShop 這個官方微服務範例來說明,從一片空白的 console log 開始,到最後可以在 Grafana 裡 3 分鐘內完成跨服務的根因分析,中間每一步都有一個具體的痛點觸發它。
如果你還在思考「為什麼要做可觀測性」這個更前端的問題,可以先看我 2025 那場 為什麼我們需要 Observability?——那場講的是 Why。這篇文章假設你已經被說服,接下來要回答的是 How。
eShop 是 .NET 官方的電商微服務範例,包含 Catalog、Basket、Ordering、Identity 等服務,透過 HTTP 和 RabbitMQ 互相溝通,架構夠貼近真實場景。如果你還沒跑過這個專案,推薦先 clone 下來感受一下多服務同時運作的複雜度。
這篇文章不是「可觀測性完全指南」,而是一份演進記錄:每個工具在什麼情境下被引入、它解決了什麼問題。若對以上內容有問題或不清楚的地方,歡迎提出來一起討論。
這篇是「.NET 可觀測性四部曲」的第一篇,講個人工程師怎麼從 0 到 1 把工具裝起來。如果你讀完之後在團隊層級、方法論層級、3.0 反思層還有進一步的問題,後三篇是對應的延伸:
四篇對應 Observability 1.0 → AI x o11y 的演進路徑,可以從任一篇進入。
起點:一片空白的 Console
場景:開發初期。 你剛把 eShop 微服務專案 clone 下來、
實際截圖 看起來沒什麼問題。直到第一次出問題。
第一階段:系統掛了,但不知道是誰的問題
情境
場景:開發階段,前後端串接除錯。 前端工程師在 channel 回報結帳功能串接後端 API 失敗——畫面卡在 spinning、沒有具體錯誤訊息。你接到請求要找出問題。
打開各服務的 terminal,每個服務看起來都在跑,沒有明顯的 exception。問題是:eShop 的結帳流程依序呼叫 Basket API → Ordering API → Payment Service,三個服務各跑在不同 port,只能人工比對時間戳記來找到請求斷在哪裡——這樣的偵錯方式在微服務架構下非常耗時。
解法:OpenTelemetry Traces + TraceId
引入 OpenTelemetry Tracing,讓每一個跨服務請求都帶上同一個
安裝套件:
在
加入 OTel 後,console 輸出變成:
技術重點
W3C TraceContext 規範定義了
小小建議:
改變了什麼:找到問題服務的時間從 1 小時降到 15 分鐘。
第二階段:知道哪個服務壞了,但不知道為什麼
情境
場景:仍在開發階段,承接前一段。 Trace 把問題定位到 Ordering.API 之後,下一步是看 log 找根本原因——但這時候會發現一個更深的問題:log 雖然有,但內容太貧乏,看了等於沒看。
TraceId 告訴我問題出在 Ordering.API。打開它的 log:
解法:結構化日誌(Structured Logging)
改用結構化 log,讓每筆 log 帶有可查詢的具名欄位,而不只是人類可讀的字串。
安裝套件:
在
在 Controller 加上有意義的 log:
加入結構化 log 後,console 輸出變成:
Before : 「An error occurred.」
After : 帶有 BuyerId、Sku、TraceId 的 JSON log
技術重點
有一個常見錯誤值得特別注意:不要用字串插值
改變了什麼:找到根本原因的時間從 15 分鐘降到 5 分鐘。
第三階段:問題解了,但不知道系統快撐不住了
情境
場景:整合完畢,準備上 Production。 系統 dev 階段該修的都修了,QA 也跑過了。在某次 sprint review,PM 提了一個情境讓你冷汗:「下個月雙 11 大促預估流量是平常的 5 倍,你怎麼確定當天不會出事?我們需要在事情發生之前就知道。」
這個問題你答不出來——因為現在的觀測能力是「事情發生了再追查」,不是「事情發生前先預警」。
事實上更早一次大促活動就已經給過教訓:Ordering 服務的回應時間悄悄從 50ms 爬到 800ms,這個過程花了將近 40 分鐘,一直到出現大量 timeout、用戶開始投訴,工程師才發現。事後回頭看,這 40 分鐘完全可以被提早預警到。
事後救火的成本遠高於事前預警,這個體驗讓我意識到缺少了 Metrics 這一塊。
解法:Metrics 監控水位
引入 OpenTelemetry Metrics,持續輸出服務健康指標,並在 Grafana 設定告警閾值。
安裝套件:
在
自訂業務 Metrics:
關鍵監控指標與告警建議:
改變了什麼:從事後救火變成事前預警,讓工程師有機會在用戶感受到問題之前主動介入。
第四階段:Metrics 說 latency 高,但不知道是哪段 code 慢
情境
場景:上線後,SRE 通知異常。 系統已經上 Production 一段時間。某次 sprint 上版後第三天,SRE 在 Slack 把告警截圖丟出來:「你們的 Ordering.API 從早上 10 點開始 p99 latency 越來越高,剛剛破 1 秒,幫我看一下。」這是事前預警機制起作用——你還來得及在 SLO 違反之前介入,但接下來要找出「到底哪一段 code 變慢了」。
打開 Tempo 看了 trace,時間集中在某個 Handler 的 span。但那個 Handler 裡呼叫了三個 repository method,不確定是哪一個慢,也不確定是 SQL 的問題還是記憶體分配造成 GC pause。
需要比 trace span 更細的執行細節。
解法:EF Core Instrumentation + Profiling
Step 1:先加 EF Core SQL Trace
大多數效能問題都出在 SQL,建議先從這裡開始。
加上後,trace 裡每個 DB span 都會直接顯示執行的 SQL 和時間,N+1 query 問題馬上現形:
只要加
Step 2:用 dotnet-trace 找 CPU / Memory hot path
開啟 speedscope.app 上傳檔案,火焰圖會直接指出哪個 method 佔用最多 CPU 時間。
Step 3:持續監控考慮 Pyroscope
Pyroscope 的優勢是 profile data 可以對應到具體的 TraceId,讓你從「這個 trace 很慢」直接跳到「這個 trace 期間的火焰圖」,適合長期監控。
改變了什麼:效能問題的診斷從「加 log 猜測、部署、等重現」變成「直接從 trace 和火焰圖找到根源」。
第五階段:訊號豐富了,但只有本機 console 才看得到
情境
場景:服務數量上升、多個 instance 規模化。 服務從一個變多個、每個又被 scale out 成多個 instance、開發 / staging / production 環境各有一份,到處 ssh 進機器拉 console log 已經不可行——值班的 SRE 也沒辦法在自己機器看到 production 的訊號。console 的時代已經結束。
歷經四個演進階段,console 現在長這樣:
訊號確實豐富很多。但 console 有幾個根本限制:沒辦法跨服務做時間軸查詢、值班的 SRE 沒辦法在自己機器上看到生產環境的 log、也沒有人在「值班查 log」以外的時間主動監控系統。
Console 是開發時的觀察窗,不是生產環境的解決方案。
解法:OTLP Exporter → Grafana Stack
把 console exporter 換成 OTLP,讓訊號送到可以被集中查詢的後端:
切換到 OTLP Exporter:
Grafana 三柱串聯的調查流程
這是可觀測性真正讓工程師「有感」的一刻:
整個調查過程不超過 3 分鐘,不需要 ssh 進任何機器、不需要 grep。
捷徑:用 .NET Aspire 跳過五個階段
寫到這裡你可能會想:「五個階段都要手動做太累。」確實——而且這條路在 .NET 8 之後其實有捷徑。
Microsoft 推的 .NET Aspire 把這篇文章所有觀測性設定壓縮成「開專案就有」:一行
但 Aspire 主題夠豐富,獨立寫一篇才講得清楚——ServiceDefaults 的設計原則、AppHost 編譯時拓撲、Aspire Manifest 導出 K8s / ACA、跟 Grafana Stack 的混合策略等等。我會另外寫一篇〈.NET Aspire × Observability:捷徑跟代價〉做完整介紹,本篇主軸是「從零到一手動走一遍」——這個過程帶來的理解價值,跟用 Aspire 一鍵就有的工具能力,是兩件事。
附錄:本地跑起完整 Grafana Stack
以下 docker-compose 可以在本機啟動完整的可觀測性後端,對應上面所有範例的 exporter 設定。
目錄結構:
啟動方式:
服務的
整體演進回顧
這裡整理一下五個階段的演進脈絡,以及每次引入的觸發原因:
小結
回顧這五個演進階段,每一個工具的引入都有一個具體的痛點觸發它,沒有人在第一天就建好完整的可觀測性堆疊——在那些痛點出現之前,這些工具也不值得引入。
我自己的習慣是:每次系統出問題,問自己「如果我有什麼資訊,這個問題可以更快解決?」然後把那個資訊加進去。可觀測性就是這樣一點一點長出來的,而不是某次架構設計決策的產物。
希望這篇文章對想要在 .NET 微服務專案導入可觀測性的朋友有幫助,若以上內容有不清楚的地方,歡迎留言討論,Happy Coding :)
技術速查表
Tracing
Logging
Metrics
Export
Profiling
Dev
參考
dotnet/eShop
OpenTelemetry .NET
.NET Aspire Telemetry
Serilog Documentation
Grafana LGTM Stack
OpenTelemetry Collector Contrib
W3C TraceContext Specification
Speedscope 火焰圖瀏覽器
可觀測性不是一次性的架構決策,而是跟著痛點長出來的。
在 .NET Conf 2024 有機會分享了一段關於可觀測性實踐的歷程,收到不少朋友的回饋,說希望能看到更完整的文字版。這篇文章就是那次分享的延伸,我會用 dotnet/eShop 這個官方微服務範例來說明,從一片空白的 console log 開始,到最後可以在 Grafana 裡 3 分鐘內完成跨服務的根因分析,中間每一步都有一個具體的痛點觸發它。
如果你還在思考「為什麼要做可觀測性」這個更前端的問題,可以先看我 2025 那場 為什麼我們需要 Observability?——那場講的是 Why。這篇文章假設你已經被說服,接下來要回答的是 How。
eShop 是 .NET 官方的電商微服務範例,包含 Catalog、Basket、Ordering、Identity 等服務,透過 HTTP 和 RabbitMQ 互相溝通,架構夠貼近真實場景。如果你還沒跑過這個專案,推薦先 clone 下來感受一下多服務同時運作的複雜度。
這篇文章不是「可觀測性完全指南」,而是一份演進記錄:每個工具在什麼情境下被引入、它解決了什麼問題。若對以上內容有問題或不清楚的地方,歡迎提出來一起討論。
這篇是「.NET 可觀測性四部曲」的第一篇,講個人工程師怎麼從 0 到 1 把工具裝起來。如果你讀完之後在團隊層級、方法論層級、3.0 反思層還有進一步的問題,後三篇是對應的延伸:
- 第一篇(本文):從 0 到 1 — 用 eShop 踩坑實錄(工具層)
- 第二篇:落地之後 — 成本、規模化與 SLO 的三個真相(組織治理層)
- 第三篇:從可觀測性到 ODD — 把觀測性左移到開發流程的五個步驟(方法論層)
- 第四篇:AI x Observability — 當 AI 答對了,但沒人知道為什麼(反思層)
四篇對應 Observability 1.0 → AI x o11y 的演進路徑,可以從任一篇進入。
起點:一片空白的 Console
場景:開發初期。 你剛把 eShop 微服務專案 clone 下來、
dotnet run 把各服務跑起來,準備接著開發新功能。各服務啟動後的 console 輸出大概是這樣:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
實際截圖 看起來沒什麼問題。直到第一次出問題。
第一階段:系統掛了,但不知道是誰的問題
情境
場景:開發階段,前後端串接除錯。 前端工程師在 channel 回報結帳功能串接後端 API 失敗——畫面卡在 spinning、沒有具體錯誤訊息。你接到請求要找出問題。
打開各服務的 terminal,每個服務看起來都在跑,沒有明顯的 exception。問題是:eShop 的結帳流程依序呼叫 Basket API → Ordering API → Payment Service,三個服務各跑在不同 port,只能人工比對時間戳記來找到請求斷在哪裡——這樣的偵錯方式在微服務架構下非常耗時。
解法:OpenTelemetry Traces + TraceId
引入 OpenTelemetry Tracing,讓每一個跨服務請求都帶上同一個
TraceId,讓呼叫鏈可以被串起來,不再需要人工比對。
安裝套件:
dotnet add package OpenTelemetry.Extensions.Hosting dotnet add package OpenTelemetry.Instrumentation.AspNetCore dotnet add package OpenTelemetry.Instrumentation.Http dotnet add package OpenTelemetry.Exporter.Console
在
Program.cs 加入:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("ordering-api"))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter()
);
加入 OTel 後,console 輸出變成:
Activity.TraceId: 3e2a1b4c8d9f0e1a2b3c4d5e6f7a8b9c
Activity.SpanId: 1a2b3c4d5e6f7a8b
Activity.ParentSpanId: 9f8e7d6c5b4a3f2e
Activity.DisplayName: POST /api/orders
Activity.Kind: Server
Activity.Duration: 00:00:02.3421
Activity.StatusCode: Error
Activity.Tags:
http.method: POST
http.status_code: 500
技術重點
W3C TraceContext 規範定義了
traceparent header。AddHttpClientInstrumentation() 在發出 outgoing request 時會自動帶上這個 header;下游服務的 AddAspNetCoreInstrumentation() 則自動解析它並建立子 Span。整個傳遞機制不需要額外的程式碼。
小小建議:
SetResourceBuilder 加上 service name,在多服務場景下讓 span 的來源一目了然,這個習慣養起來之後在查 trace 時省很多時間。
改變了什麼:找到問題服務的時間從 1 小時降到 15 分鐘。
第二階段:知道哪個服務壞了,但不知道為什麼
情境
場景:仍在開發階段,承接前一段。 Trace 把問題定位到 Ordering.API 之後,下一步是看 log 找根本原因——但這時候會發現一個更深的問題:log 雖然有,但內容太貧乏,看了等於沒看。
TraceId 告訴我問題出在 Ordering.API。打開它的 log:
info: Ordering.API[0]
An error occurred.
fail: Ordering.API[0]
Exception thrown.
orderId 是什麼?哪個 buyer?Exception 的 message 呢?這兩行看了等於沒看。
解法:結構化日誌(Structured Logging)
改用結構化 log,讓每筆 log 帶有可查詢的具名欄位,而不只是人類可讀的字串。
安裝套件:
dotnet add package Serilog.AspNetCore dotnet add package Serilog.Enrichers.Span # 自動注入當前 Activity 的 TraceId / SpanId
在
Program.cs 設定:
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.WithSpan() // TraceId / SpanId 自動附上
.Enrich.WithMachineName()
.WriteTo.Console(new RenderedCompactJsonFormatter())
);
在 Controller 加上有意義的 log:
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderCommand command)
{
_logger.LogInformation(
"Creating order for buyer {BuyerId}, item count: {ItemCount}",
command.BuyerId,
command.Items.Count
);
try
{
var result = await _mediator.Send(command);
_logger.LogInformation(
"Order {OrderId} created in {ElapsedMs}ms",
result.OrderId,
sw.ElapsedMilliseconds
);
return Ok(result);
}
catch (InsufficientStockException ex)
{
_logger.LogWarning(
"Order rejected for buyer {BuyerId}: insufficient stock for SKU {Sku}",
command.BuyerId,
ex.Sku
);
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error creating order for buyer {BuyerId}",
command.BuyerId
);
throw;
}
}
加入結構化 log 後,console 輸出變成:
{
"Timestamp": "2024-01-15T14:32:01.123Z",
"Level": "Warning",
"MessageTemplate": "Order rejected for buyer {BuyerId}: insufficient stock for SKU {Sku}",
"BuyerId": "usr_9981",
"Sku": "SKU-4421",
"TraceId": "3e2a1b4c8d9f0e1a2b3c4d5e6f7a8b9c",
"SpanId": "1a2b3c4d5e6f7a8b",
"MachineName": "ordering-api-pod-7f9b"
}
Before : 「An error occurred.」
After : 帶有 BuyerId、Sku、TraceId 的 JSON log
技術重點
有一個常見錯誤值得特別注意:不要用字串插值
$"buyer {id}" 寫 log message,這會讓 id 的值被拼進字串,無法作為獨立欄位查詢。一定要用 named template {BuyerId},Serilog 才能把它存成結構化欄位。
WithSpan() 會自動把當前 Activity 的 TraceId / SpanId 注入每筆 log,不需要手動傳遞,這個 enricher 幾乎是必裝的。
改變了什麼:找到根本原因的時間從 15 分鐘降到 5 分鐘。
第三階段:問題解了,但不知道系統快撐不住了
情境
場景:整合完畢,準備上 Production。 系統 dev 階段該修的都修了,QA 也跑過了。在某次 sprint review,PM 提了一個情境讓你冷汗:「下個月雙 11 大促預估流量是平常的 5 倍,你怎麼確定當天不會出事?我們需要在事情發生之前就知道。」
這個問題你答不出來——因為現在的觀測能力是「事情發生了再追查」,不是「事情發生前先預警」。
事實上更早一次大促活動就已經給過教訓:Ordering 服務的回應時間悄悄從 50ms 爬到 800ms,這個過程花了將近 40 分鐘,一直到出現大量 timeout、用戶開始投訴,工程師才發現。事後回頭看,這 40 分鐘完全可以被提早預警到。
事後救火的成本遠高於事前預警,這個體驗讓我意識到缺少了 Metrics 這一塊。
解法:Metrics 監控水位
引入 OpenTelemetry Metrics,持續輸出服務健康指標,並在 Grafana 設定告警閾值。
安裝套件:
dotnet add package OpenTelemetry.Instrumentation.Runtime dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore
在
Program.cs 加入:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("ordering-api"))
.AddAspNetCoreInstrumentation() // http.server.request.duration、active_requests
.AddRuntimeInstrumentation() // dotnet.gc.*、thread pool、memory
.AddPrometheusExporter()
);
app.MapPrometheusScrapingEndpoint(); // 暴露 /metrics 給 Prometheus scrape
自訂業務 Metrics:
public class OrderingMetrics
{
private readonly Counter<long> _ordersTotal;
private readonly Histogram<double> _processingDuration;
public OrderingMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("eShop.Ordering");
_ordersTotal = meter.CreateCounter<long>(
"orders.total",
description: "Total orders by outcome"
);
_processingDuration = meter.CreateHistogram<double>(
"orders.processing.duration",
unit: "ms"
);
// Gauge:從外部 pull 當前值,不需要主動 push
meter.CreateObservableGauge(
"orders.pending.count",
() => _repo.GetPendingCount(),
description: "Orders waiting to be processed"
);
}
public void RecordOrder(string status, double durationMs)
{
_ordersTotal.Add(1, new("status", status));
_processingDuration.Record(durationMs, new("status", status));
}
}
關鍵監控指標與告警建議:
http.server.request.duration(p99):> 500ms 持續 5 分鐘 → API 整體回應變慢http.server.active_requests:> 150 → 請求積壓orders.total{status="failed"}比率:> 5% → 訂單失敗率異常dotnet.gc.heap.total_allocated:異常上升趨勢 → 疑似 memory leak- DB connection pool 使用率:> 80% → 連線池壓力大
改變了什麼:從事後救火變成事前預警,讓工程師有機會在用戶感受到問題之前主動介入。
第四階段:Metrics 說 latency 高,但不知道是哪段 code 慢
情境
場景:上線後,SRE 通知異常。 系統已經上 Production 一段時間。某次 sprint 上版後第三天,SRE 在 Slack 把告警截圖丟出來:「你們的 Ordering.API 從早上 10 點開始 p99 latency 越來越高,剛剛破 1 秒,幫我看一下。」這是事前預警機制起作用——你還來得及在 SLO 違反之前介入,但接下來要找出「到底哪一段 code 變慢了」。
打開 Tempo 看了 trace,時間集中在某個 Handler 的 span。但那個 Handler 裡呼叫了三個 repository method,不確定是哪一個慢,也不確定是 SQL 的問題還是記憶體分配造成 GC pause。
需要比 trace span 更細的執行細節。
解法:EF Core Instrumentation + Profiling
Step 1:先加 EF Core SQL Trace
大多數效能問題都出在 SQL,建議先從這裡開始。
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddEntityFrameworkCoreInstrumentation(options =>
{
options.SetDbStatementForText = true; // 把實際 SQL 記進 span attribute
})
);
加上後,trace 裡每個 DB span 都會直接顯示執行的 SQL 和時間,N+1 query 問題馬上現形:
Span: SELECT o.* FROM Orders WHERE BuyerId = @p0 3ms Span: SELECT i.* FROM OrderItems WHERE OrderId = @p1 2ms Span: SELECT i.* FROM OrderItems WHERE OrderId = @p2 2ms Span: SELECT i.* FROM OrderItems WHERE OrderId = @p3 2ms ... × 47 次
只要加
.Include(o => o.Items) 就能解決,但沒有這個 span 的話幾乎找不到問題在哪。
Step 2:用 dotnet-trace 找 CPU / Memory hot path
# 安裝工具(只需一次)
dotnet tool install -g dotnet-trace
# 對線上 process 收集 30 秒的 CPU sample
dotnet-trace collect --process-id $(pgrep -f Ordering.API) \
--profile cpu-sampling \
--duration 00:00:30 \
-o ordering-trace.nettrace
# 轉換成 Speedscope 格式
dotnet-trace convert ordering-trace.nettrace --format Speedscope
開啟 speedscope.app 上傳檔案,火焰圖會直接指出哪個 method 佔用最多 CPU 時間。
dotnet-trace 是 .NET 內建工具,不需要安裝 agent,臨時診斷非常好用。
Step 3:持續監控考慮 Pyroscope
dotnet add package Pyroscope.OpenTelemetry
Pyroscope 的優勢是 profile data 可以對應到具體的 TraceId,讓你從「這個 trace 很慢」直接跳到「這個 trace 期間的火焰圖」,適合長期監控。
改變了什麼:效能問題的診斷從「加 log 猜測、部署、等重現」變成「直接從 trace 和火焰圖找到根源」。
第五階段:訊號豐富了,但只有本機 console 才看得到
情境
場景:服務數量上升、多個 instance 規模化。 服務從一個變多個、每個又被 scale out 成多個 instance、開發 / staging / production 環境各有一份,到處 ssh 進機器拉 console log 已經不可行——值班的 SRE 也沒辦法在自己機器看到 production 的訊號。console 的時代已經結束。
歷經四個演進階段,console 現在長這樣:
{
"Timestamp": "2024-01-15T14:32:01.123Z",
"Level": "Warning",
"BuyerId": "usr_9981",
"Sku": "SKU-4421",
"TraceId": "3e2a1b4c8d9f0e1a2b3c4d5e6f7a8b9c",
"SpanId": "1a2b3c4d5e6f7a8b",
"MachineName": "ordering-api-7f9b"
}
訊號確實豐富很多。但 console 有幾個根本限制:沒辦法跨服務做時間軸查詢、值班的 SRE 沒辦法在自己機器上看到生產環境的 log、也沒有人在「值班查 log」以外的時間主動監控系統。
Console 是開發時的觀察窗,不是生產環境的解決方案。
解法:OTLP Exporter → Grafana Stack
把 console exporter 換成 OTLP,讓訊號送到可以被集中查詢的後端:
服務(OTel SDK)
↓ gRPC / OTLP Protocol (port 4317)
OpenTelemetry Collector
↓
┌──────────────────────────────────────┐
│ Traces → Grafana Tempo (TraceQL) │
│ Logs → Grafana Loki (LogQL) │
│ Metrics → Prometheus (PromQL) │
└──────────────────────────────────────┘
↓
Grafana(統一查詢介面 + Dashboard + 告警)
切換到 OTLP Exporter:
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
var otlpEndpoint = new Uri(builder.Configuration["OTLP_ENDPOINT"] ?? "http://otel-collector:4317");
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("ordering-api", serviceVersion: "1.0.0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(opt => opt.RecordException = true)
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation(opt => opt.SetDbStatementForText = true)
.AddOtlpExporter(opt => opt.Endpoint = otlpEndpoint)
)
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter(opt => opt.Endpoint = otlpEndpoint)
);
// Logs 也透過 OTLP 送出(不再需要 Serilog WriteTo.Console)
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter(opt => opt.Endpoint = otlpEndpoint);
});
Grafana 三柱串聯的調查流程
這是可觀測性真正讓工程師「有感」的一刻:
- Grafana Dashboard 看到 p99 latency 在 14:32 出現峰值
- 點擊峰值上的 Exemplar → 自動跳到 Grafana Tempo,顯示那個時間點的某筆 TraceId
- Tempo 看到完整跨服務呼叫鏈,找到 Ordering.API 某個 span 耗時 900ms
- 點擊 span 旁邊的「Logs」按鈕 → 連結到 Grafana Loki
- Loki 顯示同一個 TraceId 在那段時間的所有結構化 log,找到
InsufficientStockException
整個調查過程不超過 3 分鐘,不需要 ssh 進任何機器、不需要 grep。
捷徑:用 .NET Aspire 跳過五個階段
寫到這裡你可能會想:「五個階段都要手動做太累。」確實——而且這條路在 .NET 8 之後其實有捷徑。
Microsoft 推的 .NET Aspire 把這篇文章所有觀測性設定壓縮成「開專案就有」:一行
builder.AddServiceDefaults() 等同於 Stage 1~4 的 OTel 全部設定、AppHost 取代 docker-compose 把 Stage 5 的 Grafana stack 也省掉。
但 Aspire 主題夠豐富,獨立寫一篇才講得清楚——ServiceDefaults 的設計原則、AppHost 編譯時拓撲、Aspire Manifest 導出 K8s / ACA、跟 Grafana Stack 的混合策略等等。我會另外寫一篇〈.NET Aspire × Observability:捷徑跟代價〉做完整介紹,本篇主軸是「從零到一手動走一遍」——這個過程帶來的理解價值,跟用 Aspire 一鍵就有的工具能力,是兩件事。
附錄:本地跑起完整 Grafana Stack
以下 docker-compose 可以在本機啟動完整的可觀測性後端,對應上面所有範例的 exporter 設定。
目錄結構:
observability/
├── docker-compose.yml
├── otel-collector-config.yaml
├── prometheus.yml
└── grafana/
└── provisioning/
└── datasources/
└── datasources.yaml
docker-compose.yml:
version: "3.9"
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.96.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC(服務連這裡)
- "4318:4318" # OTLP HTTP
depends_on:
- tempo
- loki
- prometheus
tempo:
image: grafana/tempo:2.4.0
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo.yaml:/etc/tempo.yaml
- tempo-data:/var/tempo
ports:
- "3200:3200"
loki:
image: grafana/loki:2.9.4
command: ["-config.file=/etc/loki/local-config.yaml"]
volumes:
- loki-data:/loki
ports:
- "3100:3100"
prometheus:
image: prom/prometheus:v2.50.0
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--enable-feature=exemplar-storage" # 啟用 Exemplar,讓 metrics 連結 TraceId
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- "9090:9090"
grafana:
image: grafana/grafana:10.3.3
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_FEATURE_TOGGLES_ENABLE=traceqlEditor
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
depends_on:
- tempo
- loki
- prometheus
volumes:
tempo-data:
loki-data:
prometheus-data:
grafana-data:
otel-collector-config.yaml:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
filter/health:
traces:
span:
- 'attributes["http.route"] == "/health"'
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
loki:
endpoint: http://loki:3100/loki/api/v1/push
tls:
insecure: true
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
service:
pipelines:
traces:
receivers: [otlp]
processors: [filter/health, batch]
exporters: [otlp/tempo]
logs:
receivers: [otlp]
processors: [batch]
exporters: [loki]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheusremotewrite]
grafana/provisioning/datasources/datasources.yaml(自動設定三柱串聯):
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
jsonData:
exemplarTraceIdDestinations:
- name: traceID
datasourceUid: tempo # Exemplar 點擊後跳到 Tempo
- name: Tempo
uid: tempo
type: tempo
url: http://tempo:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki # Trace span 點擊後跳到 Loki
filterByTraceID: true
lokiSearch:
datasourceUid: loki
serviceMap:
datasourceUid: prometheus
- name: Loki
uid: loki
type: loki
url: http://loki:3100
jsonData:
derivedFields:
- matcherRegex: '"TraceId":"(\w+)"'
name: TraceID
url: "$${__value.raw}"
datasourceUid: tempo # Log 裡的 TraceId 點擊後跳到 Tempo
啟動方式:
cd observability docker compose up -d open http://localhost:3000
服務的
OTLP_ENDPOINT 環境變數設為 http://localhost:4317,重啟 eShop 後訊號就會開始流進來。
整體演進回顧
這裡整理一下五個階段的演進脈絡,以及每次引入的觸發原因:
- 起點:空白 console → 數小時才能定位
- 第一階段:不知道哪個服務出問題 → 引入 OTel Traces + TraceId → 約 60 分鐘
- 第二階段:知道服務但不知道原因 → 結構化 Logs → 約 15 分鐘
- 第三階段:出問題才知道,太晚了 → Metrics + 告警 → 事前預警
- 第四階段:知道慢但不知道哪裡慢 → EF Core Trace + Profiling → 數據導向診斷
- 第五階段:訊號只在本機 console → Grafana 三柱串聯 → 約 3 分鐘
小結
回顧這五個演進階段,每一個工具的引入都有一個具體的痛點觸發它,沒有人在第一天就建好完整的可觀測性堆疊——在那些痛點出現之前,這些工具也不值得引入。
我自己的習慣是:每次系統出問題,問自己「如果我有什麼資訊,這個問題可以更快解決?」然後把那個資訊加進去。可觀測性就是這樣一點一點長出來的,而不是某次架構設計決策的產物。
希望這篇文章對想要在 .NET 微服務專案導入可觀測性的朋友有幫助,若以上內容有不清楚的地方,歡迎留言討論,Happy Coding :)
技術速查表
Tracing
OpenTelemetry.Instrumentation.AspNetCore:HTTP request spanOpenTelemetry.Instrumentation.Http:Outgoing HTTP spanOpenTelemetry.Instrumentation.EntityFrameworkCore:SQL trace
Logging
Serilog.AspNetCore:結構化 logSerilog.Enrichers.Span:自動注入 TraceId / SpanId
Metrics
OpenTelemetry.Instrumentation.Runtime:.NET runtime metricsOpenTelemetry.Exporter.Prometheus.AspNetCore:暴露 /metrics endpoint
Export
OpenTelemetry.Exporter.OpenTelemetryProtocol:OTLP(對接 Collector)
Profiling
- dotnet-trace(內建):臨時 CPU / memory 診斷
Pyroscope.OpenTelemetry:持續式 profiling
Dev
- .NET Aspire Dashboard:開發環境零設定整合視圖
參考
dotnet/eShop
OpenTelemetry .NET
.NET Aspire Telemetry
Serilog Documentation
Grafana LGTM Stack
OpenTelemetry Collector Contrib
W3C TraceContext Specification
Speedscope 火焰圖瀏覽器







