在 ASP .NET Core 中使用 OpenTelemetry,為應用程式埋下觀測點

這篇文章主要會說明為什麼選擇使用 OpenTelemetry 作為處理追蹤資料的套件,還有如何利用 .NET System.Diagnostics 函式庫來產生追蹤資料,並交由 OpenTelemetry 來接手處理後續流程的方式。

為什麼選擇 OpenTelemetry

OpenTelemetry 是一個可以採集 trace, metric, log 三樣遙測資料的整合工具,支援多種程式語言,同時又是 CNCF 裡的項目。以往想要導入 APM 的工具時,基本上就只能使用那家工具所開發的套件,日後想要換工具,就會遇到服務內部的程式碼都要改用相對應套件的情況,亦增加了更換工具的成本。現在的話,只要裝 OpenTelemetry 這一套,就可以隨意更換後面的 APM 工具,不會因為更換套件的成本很高而被特定的工具綁住。OpenTelemetry Protocol 目前已經有很多 APM 工具支援它的格式,例如 Grafana、Elastic APM。

當然,OpenTelemetry 也不是說有其他 APM 工具支援就完美無缺的,要看支援程度高不高,有機率出現搭配自家套件才能讓整套工具更好用、有一好沒兩好的情況,這點就要自行評估。Grafana Tempo 對於 OpenTelemetry Protocol 格式的支援度很高,欄位的對應也一致,但是對於用追蹤的資料來製作統計資料,這點就難度很高,甚至沒辦法製作,當然跟我對 Grafana 製作圖表很不熟也有關係。後續也有嘗試使用 Elastic APM 搭配 OpenTelemetry(送到 OTel Collector 再轉到 Elastic Agent),也試過搭配 Elastic 自家的套件 Elastic.Apm(直送 Elastic Agent),結果兩者的資料在 Elastic APM 上發揮的效果,還是自家套件較能完整發揮。

運作流程

圖1:運作流程

整個運作流程也很簡單,.NET 的應用程式裡使用 System.Diagnostics 函式庫產生追蹤資料(手動埋點),然後交由 OpenTelemetry 套件接手處理自動側錄、輸出的目的地等設定。處理完成後,再透過 OpenTelemetry Protocol 來傳到採集器(OpenTelemetry Collector 或是其他採集器),之後採集器再送到 APM 工具內,交由我們查看。

OpenTelemetry 套件

這裡提供一些我常用的套件,GitHub 裡也有很多官方協作者(contribute)所建立的套件,可依需求選用(裡面地雷還不少,有的會互相衝突)。各套件的使用說明都在 GitHub 裡,沒有額外的網站說明。另外,有一些套件目前還在預覽版,記得要先允許使用預覽版的套件,這樣才能看到。

套件版本可能較舊,若使用新版本,可能用法會不同,建議搭配官方文件。

1
2
3
4
5
6
7
OpenTelemetry 1.4.0
OpenTelemetry.Exporter.Console 1.4.0
OpenTelemetry.Exporter.OpenTelemetryProtocol 1.4.0
OpenTelemetry.Extensions.Hosting 1.4.0
OpenTelemetry.Instrumentation.AspNetCore 1.0.0-rc9.14
OpenTelemetry.Instrumentation.Http 1.0.0-rc9.14
OpenTelemetry.Instrumentation.SqlClient 1.0.0-rc9.14

接下來用函式庫的類型來分類,簡單說明一下各套件的用途:

核心

沒了它,接下來的內容可以不用看了。

套件 說明
OpenTelemetry OpenTelemetry 的核心套件
OpenTelemetry.Extensions.Hosting 提供 tracing 和 metrics 自動開始和停止側錄的擴充方法,簡化 OpenTelemetry SDK 的生命週期

Exporter

將資料輸出到指定位置,有很多輸出的位置,也可以同時使用多個,例如:Console, InMemory, Jaeger, OpenTelemetry Protocol 等。

套件 說明
OpenTelemetry.Exporter.Console System.Diagnostics.Activity 產生的資料輸出到 Console,方便在開發的時候「人工」觀測和解析
OpenTelemetry.Exporter.OpenTelemetryProtocol System.Diagnostics.Activity 產生的資料轉成 OpenTelemetry Protocol 格式並輸出到指定位置,通常是送到 collector 或是 agent。提供 gRPC 和 HTTP 的方式傳送

Instrumentation

自動側錄的工具,不同的工具會自動側錄它注重的資料,例如 HTTP 就會注重 HTTP 動詞、status code、url、query string 等,這個就交由各位自行在 Console 或是追蹤資料中觀察。需要注意的是,這些套件和 OpenTelemetry .NET Automatic Instrumentation 不同,這個需要安裝在主機上,然後就能自動側錄主機上的所有服務,基本上不需要修改服務的程式碼,有興趣的可以自行研究(這東東也是個坑)。

套件 說明
OpenTelemetry.Instrumentation.AspNetCore 自動側錄 ASP .NET Core 專案的資料,需要注意,它只會側錄這個專案,其他的一概不理
OpenTelemetry.Instrumentation.Http 自動側錄 HttpClient 這個物件的資料
OpenTelemetry.Instrumentation.SqlClient 自動側錄 SqlClient 這個物件的資料,同時支援 Microsoft.Data.SqlClientSystem.Data.SqlClient,可以側錄 SQL 語句

實作

OpenTelemetry 會搭配 .NET 提供的 System.Diagnostics 函式庫來取得追蹤資料,之後 OpenTelemetry 就會依照設定開始介入處理。在實作的時候可以選擇埋點或不埋點,不埋點只要在Program.cs中調整好配置就能直接使用,可以直接看設定 OpenTelemetry的章節。想要埋點,看比較詳細的流程,可以看完埋點。關於 .NET OpenTelemetry 的詳細使用說明就不贅述了,可以參考下面的文件:

埋點

一開始只要埋得廣就可以了,讓各個有相依到的服務都能串聯起來,之後有必要時再來針對性地埋點,而且埋得愈多資料量愈大,後續可能就會衍生出空間和查詢效能的問題。

提供名詞的對照,這樣比較好理解在 .NET 裡的物件對應到什麼:

OpenTelemetry .NET
Tracer ActivitySource
Span Activity

注意事項:

  • ActivitySource:建議只建立一次,儲存在靜態變數中,同一個元件中都使用同一個 ActivitySource。如果想要獨立控制的話,請再建立一個新的 ActivitySource。ActivitySource 是一個用來建立和啟動 Activity 的物件。
  • Activity:在開始和停止的範圍內,操作要記錄的內容。

下面提供兩種記錄的方式,任君選擇。

方法一:手動為每個方法埋點

比較繁雜,但是操作空間大,能夠輕鬆微調自定義的標籤。

  1. 建立 ActivitySource

Instrumentation.cs

1
2
3
4
5
6
7
namespace OtelSample.Service;

public static class Instrumentation()
{
// 通常一個元件建立一個專用的 ActivitySource。
public static readonly ActivitySource ServiceActivitySource = new ActivitySource("OtelSample.Service");
}
  1. Activity 開始記錄

TestService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace OtelSample.Service;

public class TestService()
{
public void GetData()
{
// 記錄 using 範圍內的資料。進入 using 就開始記錄,離開就停止。
using (var activity = Instrumentation.ServiceActivitySource.Start("OtelSample.Service.TestService.GetData"))
{
// do something
}
}
}

方法二:使用 AOP 套件

這種方式在類別或是方法上掛上自己寫好的 attribute 後,就能自動為目標方法在執行前或執行後做一些事,概念和 MVC 中的 Filter 一樣。AOP 的概念網路上的文章很多,搜尋一下就有。有興趣想看怎麼實作一個 AOP 框架和原理的話,可以看看蔣金楠的全新升级的AOP框架Dora.Interception[6]: 框架设计和实现原理

.NET 有很多 AOP 套件可以選用,例如:

範例中將使用 Aspect Injector,詳細用法可以看 GitHub,或者看 [料理佳餚] C# 一個 Open Source 的 Compile-time AOP 框架 - AspectInjector - 軟體廚房,就不在這介紹用法。同時,也會將這個 attribute 設為共用元件,讓多個元件都能使用,所以會使用反射的方式來取得元件、類別、方法等名稱。

  1. 安裝 Aspect Injector 套件

    1
    AspectInjector 2.8.1
  2. 建立 ActivitySource

將這個類別作為取得各元件 ActivitySource 的集中處。先用元件名稱查詢有無這個名稱的 AcitvitySource,有就繼續沿用,沒有則新增一個並存下來。

Instrumentation.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace OtelSample.Common;

public static class Instrumentation
{
private static readonly List<ActivitySource> ActivitySources = new List<ActivitySource>();

public static Activity StartActivity(string componentName, string activityName)
{
var activitySource = GetActivitySource(componentName);

var activity = activitySource.StartActivity(activityName);

return activity;
}

private static ActivitySource GetActivitySource(string activityName)
{
var activitySource = ActivitySources.FirstOrDefault(q => q.Name.Contains(activityName));

if (activitySource is null)
{
activitySource = new ActivitySource(activityName);
ActivitySources.Add(activitySource);
}

return activitySource;
}
}
  1. 定義攔截器和 Advice

我們只需要記錄 public 的方法,並使用 Around 類型來包覆整個方法。

TracingAspect.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace OtelSample.Common;

[Aspect(Scope.PerInstance)]
public class TracingAspect
{
[Advice(Kind.Around, Targets = Target.Method | Target.Public)]
public object Around(
[Argument(Source.Type)] Type type,
[Argument(Source.Name)] string name,
[Argument(Source.Arguments)] object[] arguments,
[Argument(Source.Target)] Func<object[], object> target)
{
var componentName = Assembly.GetCallingAssembly().GetName().Name;
var className = type.Name;

using var activity = Instrumentation.StartActivity(componentName, $"{componentName}.{className}.{name}");

return target(arguments);
}
}
  1. 建立 attribute

TracingAttribute.cs

1
2
3
4
5
6
7
namespace OtelSample.Common;

[Injection(typeof(TracingAspect))]
public class TracingAttribute : Attribute
{

}
  1. 掛載 attribute

TestService.cs

1
2
3
4
5
6
7
8
9
10
namespace OtelSample.Service;

[Tracing]
public class TestService()
{
public void GetData()
{
// do something
}
}

設定 OpenTelemetry

程式都埋好點之後(或不埋點),接下來就可以交由 OpenTelemetry 來處理了。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 反射取得服務相關的類別庫名稱
var serviceName = Assembly.GetEntryAssembly()?.GetName().Name;
var serviceVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();

var componentPrefix = "OtelSample";

var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(q => q.GetName().Name.StartsWith(componentPrefix));
var sources = assemblies.Select(q => q.GetName().Name);

// tracing
builder.Services.AddOpenTelemetry()
.WithTracing(tracerProviderBuilder =>
tracerProviderBuilder
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService(serviceName, serviceVersion: serviceVersion))
.AddSource(sources.ToArray())
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation(options =>
{
options.RecordException = true;
})
.AddSqlClientInstrumentation(options =>
{
options.RecordException = true;
options.SetDbStatementForText = true; // 記錄 SQL 語法,EF 或 Dapper 都可以被記錄
})
.AddOtlpExporter(cfg =>
{
cfg.Endpoint = new Uri("http://localhost:4317");
cfg.Protocol = OtlpExportProtocol.Grpc;
})
.AddConsoleExporter());

最後

呼叫幾次 API,再看看你的 APM 工具,享受精美的圖表。
圖2:Tracing 甘特圖

實作時遇到什麼問題,或覺得範例寫得不完整,可以去看我 GitHub 的 Repository:zamhsu/Otel-WebApiSample

參考

延伸閱讀