使用 OpenTelemetry 構建 .NET 應用可觀測性(3):.NET SDK 概覽

前言

本文將介紹 OpenTelemetry .NET SDK 核心組件的設計和使用,主要是爲後續給大家介紹如何在 ASP.NET Core 應用程序中使用 OpenTelemetry 做鋪墊。

爲方便演示,本文使用的 Exporter 都是 Console Exporter,將數據輸出到控制檯。

概覽

我們在 OpenTelemetry 的 GitHub 倉庫中搜索 dotnet,可以看到有三個倉庫:

https://github.com/open-telemetry?q=dotnet&type=all&language=&sort=

opentelemetry-dotnet

OTel SDK 的核心庫,主要包括以下幾個部分:

  • Logging, Metrics, Tracing 等核心組件
  • ASP.NET Core 相關的常用 Instrumentation,如 AspNetCore、HttpClient、GrpcNetClient、SqlClient 等。
  • Console、Zipkin、Prometheus 等常用 Exporter
  • 依賴注入的擴展,用於在應用中快速集成 OTel

opentelemetry-dotnet-contrib

第三方貢獻的 Instrumentation 和 Exporter,比如 InfluxDB、Elasticsearch、AWS 等

opentelemetry-dotnet-instrumentation

無侵入的 Instrumentation,用於在不修改代碼的情況下,自動收集數據。

SDK 的基本使用

本文只介紹 OTel SDK 的基本使用,下面將創建一個 Console 應用程序,演示如何使用 OTel SDK。

安裝依賴

創建一個 .NET Core Console 應用程序,然後安裝下列依賴:

dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.Console

本文測試使用的是 1.6.0 版本,後期 OTel SDK 的版本可能會有所變化。

Resources

Resource 是 OTel 中的一個重要概念,用於標識應用程序的一些元數據,比如應用程序的名稱、版本、運行環境等。
Resource 的信息會被添加到 Log、Span、Metric 等數據中,用於後續的查詢和分析。

Resource 由 ResourceBuilder 構建,ResourceBuilder 有兩個方法:

ResourceBuilder.CreateDefault()

ResourceBuilder.CreateDefault():創建一個默認的 Resource,包含以下Attribute:

  • ServiceName:應用程序的名稱,可以通過 OTEL_SERVICE_NAME 環境變量設置。
  • 自定義的Attribute:可以通過 OTEL_RESOURCE_ATTRIBUTES 環境變量設置,格式爲 key1=value1,key2=value2
  • OTel SDK 的信息:包括 OTel SDK 的名稱、版本、語言等。
Environment.SetEnvironmentVariable("OTEL_SERVICE_NAME", "FooService");
// 可以直接在 OTEL_RESOURCE_ATTRIBUTES 中指定 service.name, 這樣就不需要再指定 OTEL_SERVICE_NAME 了
Environment.SetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES", "service.version=1.0.0,service.namespace=TestNamespace");

Resource resource = ResourceBuilder
    .CreateDefault()
    .Build();

foreach (var attribute in resource.Attributes)
{
    Console.WriteLine($"{attribute.Key}={attribute.Value}");
}

輸出:

service.name=FooService
service.version=1.0.0
service.namespace=TestNamespace
telemetry.sdk.name=opentelemetry
telemetry.sdk.language=dotnet
telemetry.sdk.version=1.6.0

ResourceBuilder.CreateEmpty()

ResourceBuilder.CreateEmpty():創建一個空的 Resource,可以按需求添加Attribute。

Environment.SetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES", "test.attribute=foo");

Resource resource = ResourceBuilder
    .CreateDefault()
    .AddService("FooService", "TestNamespace", "1.0.0")
    .AddTelemetrySdk()
    .AddEnvironmentVariableDetector() // 可以識別 OTEL_RESOURCE_ATTRIBUTES 環境變量
    .Build();

foreach (var attribute in resource.Attributes)
{
    Console.WriteLine($"{attribute.Key} = {attribute.Value}");
}

輸出:

test.attribute = foo
telemetry.sdk.name = opentelemetry
telemetry.sdk.language = dotnet
telemetry.sdk.version = 1.6.0
service.name = FooService
service.namespace = TestNamespace
service.version = 1.0.0
service.instance.id = 15ff37f1-5791-4afe-b130-cb947b895af3

Tracing

ActivitySource & Activity

有別於其他語言的 SDK,.NET SDK 的 Tracing 模塊是通過 ActivitySource 實現的。

ActivitySource 的 API 和 OpenTelemetry 的 API 基本是一一對應的。

通過 ActivitySource.StartActivity() 創建的 Activity 對應 OTel 中的 Span,可以被 OTel SDK 的 Tracing 模塊收集。

Activity 是 NET 以前就有的類,OTel 標準出來後,.NET 對 Activity 做了一些擴展,使其可以和 OTel 中的 Span 一一對應。

System.Diagnostics.ActivitySource 是 .NET Runtime 的一部分,如果編寫的代碼僅僅是一個收集數據的組件,可以直接使用 System.Diagnostics.ActivitySource,不需要引入 OpenTelemetry 的依賴。

ActivitySource 本質是 System.Diagnostics 命名空間裏一個發佈/訂閱模式的工具。

ActivitySource.AddActivityListener(new ActivityListener
{
    // 只監聽 TestSource1
    ShouldListenTo = source => source.Name == "TestSource1",
    // 採樣率爲 100%
    Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllDataAndRecorded,
    // 監聽 Activity 的開始和結束
    ActivityStarted = activity =>
    {
        Console.WriteLine($"Activity started: {activity.OperationName}");
    },
    ActivityStopped = activity =>
    {
        Console.WriteLine($"Activity stopped: {activity.OperationName}");
    }
});

using var activitySource1 = new ActivitySource("TestSource1");
using var activitySource2 = new ActivitySource("TestSource2");

using var activity1 = activitySource1.StartActivity("Activity1");
Console.WriteLine($"Activity1 created: {activity1 != null}");
// 如果設置 Listener,ActivitySource 將不會創建 Activity,StartActivity 返回 null
activity1?.SetTag("foo", 1);

using var activity2 = activitySource2.StartActivity("Activity2");
Console.WriteLine($"Activity2 created: {activity2 != null}");
activity2?.SetTag("bar", "Hello, World!");

輸出:

Activity started: Activity1
Activity1 created: True
Activity2 created: False
Activity stopped: Activity1

ActivitySource 可以通過 Name 來關聯 ActivityListener,只有 ActivityListener 的 ShouldListenTo 返回 true 的 ActivitySource 纔會被監聽。

在上面的例子中,我們通過 ActivitySource.StartActivity() 創建了兩個 Activity,但是隻有一個 Activity 被監聽到,這是因爲我們設置了 ShouldListenTo,只監聽 TestSource1。

如果沒有設置 ActivityListener,ActivitySource.StartActivity() 將返回 null。

所以推薦使用 ActivitySource.StartActivity() 創建的 Activity 時,使用?.操作符來避免空指針異常。

Tracing 模塊的使用

而 OpenTelemetry SDK 的 Tracing 模塊,其實就是一個 ActivityListener 的實現。

在使用 OTel 的 Tracing 模塊時,我們需要通過 TracerProvider.AddSource() 告訴 OTel SDK 實現的 ActivityListener 需要監聽哪些 ActivitySource。

var serviceName = "MyCompany.MyProduct.MyService";
var serviceVersion = "1.0.0";

var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: serviceName, serviceVersion: serviceVersion);

// 創建 Span 是通過 ActivitySource.StartActivity() 實現的,
// 所以這邊的 tracerProvider 不會被使用
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
    .SetResourceBuilder(resourceBuilder)
    .AddSource("TestSource1")
    .AddSource("TestSource2")
    .AddConsoleExporter()
    .Build();

using var activitySource1 = new ActivitySource("TestSource1");
using var activitySource2 = new ActivitySource("TestSource2");

using (var activity1 = activitySource1.StartActivity("Activity1"))
{
    activity1?.SetTag("foo", 1);
    activity1?.SetTag("bar", "Hello, World!");

    using (var activity2 = activitySource2.StartActivity("Activity2"))
    {
        activity2?.SetTag("foo", 2);
        activity2?.SetTag("bar", "Hello, OpenTelemetry!");

        Debug.Assert(activity2?.ParentId == activity1?.Id);
    }
}

輸出:

Activity.TraceId:            7497970c0c05341cadbbbd2b87b4246b
Activity.SpanId:             ce96499cd0c115fd
Activity.TraceFlags:         Recorded
Activity.ParentSpanId:       1cfead09b114a264
Activity.ActivitySourceName: TestSource2
Activity.DisplayName:        Activity2
Activity.Kind:               Internal
Activity.StartTime:          2023-09-25T13:05:36.0415480Z
Activity.Duration:           00:00:00.0000240
Activity.Tags:
    foo: 2
    bar: Hello, OpenTelemetry!
Resource associated with Activity:
    service.name: MyCompany.MyProduct.MyService
    service.version: 1.0.0
    service.instance.id: 012ed685-54a3-4ec0-879b-aff9afcbd59c
    telemetry.sdk.name: opentelemetry
    telemetry.sdk.language: dotnet
    telemetry.sdk.version: 1.6.0

Activity.TraceId:            7497970c0c05341cadbbbd2b87b4246b
Activity.SpanId:             1cfead09b114a264
Activity.TraceFlags:         Recorded
Activity.ActivitySourceName: TestSource1
Activity.DisplayName:        Activity1
Activity.Kind:               Internal
Activity.StartTime:          2023-09-25T13:05:36.0413000Z
Activity.Duration:           00:00:00.0110830
Activity.Tags:
    foo: 1
    bar: Hello, World!
Resource associated with Activity:
    service.name: MyCompany.MyProduct.MyService
    service.version: 1.0.0
    service.instance.id: 012ed685-54a3-4ec0-879b-aff9afcbd59c
    telemetry.sdk.name: opentelemetry
    telemetry.sdk.language: dotnet
    telemetry.sdk.version: 1.6.0

兩個 Activity 都有相同的 TraceId,表示它們屬於同一個 Trace。

Activity1 在 Activity2 的外層作用域中創建,所以 Activity1 是 Activity2 的 Parent,Activity2 的 ParentId 等於 Activity1 的 Id。

Metrics

MeterProvider & Meter

Metrics 模塊的使用和 Tracing 模塊類似,通過 MeterProvider 來創建 Meter,然後通過 Meter 創建 CounterGaugeMeasure 等。

var serviceName = "MyCompany.MyProduct.MyService";
var serviceVersion = "1.0.0";

var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: serviceName, serviceVersion: serviceVersion);

using MeterProvider meterProvider = Sdk.CreateMeterProviderBuilder()
    .AddMeter("Meter1")
    .SetResourceBuilder(resourceBuilder)
    .AddConsoleExporter()
    .Build();

var meter = new Meter(name: "Meter1", version: "1.0.0");

var counter = meter.CreateCounter<long>("counter");

counter.Add(100);

輸出:

Resource associated with Metric:
    service.name: MyCompany.MyProduct.MyService
    service.version: 1.0.0
    service.instance.id: 8b4fd315-6a8f-4198-ab1a-a4d11a14a431
    telemetry.sdk.name: opentelemetry
    telemetry.sdk.language: dotnet
    telemetry.sdk.version: 1.6.0

Export counter, Meter: Meter1/1.0.0
(2023-09-24T13:18:45.2247000Z, 2023-09-24T13:18:45.2277870Z] LongSum
Value: 100

Metrics 的類型

OTel 定義了以下幾種 Metric 類型:

  1. Counter:計數器,用於記錄某個事件發生的次數,比如 HTTP 請求的次數、異常的次數等。
  2. Asynchronous Counter: Counter 的異步版本。
  3. UpDownCounter:和 Counter 一樣用於記錄某個事件發生的次數,但和 Counter 不同的是,UpDownCounter 可以增加和減少。
  4. Asynchronous UpDownCounter:UpDownCounter 的異步版本。
  5. Histogram :直方圖,用於記錄某個事件的分佈情況,比如 HTTP 請求的耗時分佈。
  6. Asynchronous Gauge:異步計量器,用於記錄某個事件的瞬時值,比如 CPU 使用率、內存使用率等。

下面是各個類型在 Meter 中對應的創建方法:

  1. Counter:CreateCounter
  2. Asynchronous Counter: CreateObservableCounter
  3. UpDownCounter:CreateUpDownCounter
  4. Asynchronous UpDownCounter:CreateObservableUpDownCounter
  5. Histogram :CreateHistogram
  6. Asynchronous Gauge:CreateObservableGauge

詳細的介紹可以參考這幾篇文章:

Logging

我們知道,.NET Core 有自己的 Logging 模塊,可以通過 LoggerFactory 創建 ILogger,然後通過 ILogger 記錄日誌。

OTel SDK 的 Logging 模塊,是 ILoggerProvider 的一個實現,將其註冊到 LoggerFactory 中,就可以通過 ILogger 收集日誌。

var serviceName = "MyCompany.MyProduct.MyService";
var serviceVersion = "1.0.0";

var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: serviceName, serviceVersion: serviceVersion);

using var loggerFactory = LoggerFactory.Create(
    builder => builder.AddOpenTelemetry(
        options =>
        {
            options.AddConsoleExporter();
            options.SetResourceBuilder(resourceBuilder);
        }));

var logger = loggerFactory.CreateLogger("MyLogger");

logger.LogInformation("Hello World!");

輸出:

LogRecord.Timestamp:               2023-09-25T13:09:19.2702090Z
LogRecord.CategoryName:            MyLogger
LogRecord.Severity:                Info
LogRecord.SeverityText:            Information
LogRecord.Body:                    Hello World!
LogRecord.Attributes (Key:Value):
    OriginalFormat (a.k.a Body): Hello World!

Resource associated with LogRecord:
service.name: MyCompany.MyProduct.MyService
service.version: 1.0.0
service.instance.id: 7f14c6d0-7d8b-490a-b4dc-bfb2275da108
telemetry.sdk.name: opentelemetry
telemetry.sdk.language: dotnet
telemetry.sdk.version: 1.6.0

Tracing、Metrics、Logging 三者的數據關聯

在上面的例子中,我們單獨使用了 Tracing、Metrics、Logging 模塊,這三者的數據是相互獨立的,沒有關聯。

我們把上面的例子放在一起看下

var serviceName = "MyCompany.MyProduct.MyService";
var serviceVersion = "1.0.0";

var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: serviceName, serviceVersion: serviceVersion);

using var tracerProvider = Sdk.CreateTracerProviderBuilder()
    .SetResourceBuilder(resourceBuilder)
    .AddSource("TestSource1")
    .AddSource("TestSource2")
    .AddConsoleExporter()
    .Build();

using MeterProvider meterProvider = Sdk.CreateMeterProviderBuilder()
    .SetResourceBuilder(resourceBuilder)
    .AddMeter("Meter1")
    .AddConsoleExporter()
    .Build();

using var loggerFactory = LoggerFactory.Create(
    builder => builder.AddOpenTelemetry(
        options =>
        {
            options.AddConsoleExporter();
            options.SetResourceBuilder(resourceBuilder);
        }));

using var activitySource1 = new ActivitySource("TestSource1");
using var activitySource2 = new ActivitySource("TestSource2");

var logger = loggerFactory.CreateLogger("MyLogger");

var meter = new Meter("Meter1", "1.0.0");
var counter = meter.CreateCounter<long>("MyCounter");

using (var activity1 = activitySource1.StartActivity("Activity1"))
{
    logger.LogInformation("Hello, Activity1!");
    using (var activity2 = activitySource2.StartActivity("Activity2"))
    {
        logger.LogInformation("Hello, Activity2!");
        counter.Add(100);
    }
}

下面是輸出內容的整理:

  1. 兩個 Activity 的 TraceId 相同,表示它們屬於同一個 Trace,Activity1 是 Activity2 的 Parent。
  2. 兩次日誌輸出的 TraceId 是一樣的,表示這兩條日誌屬於同一個 Trace,但是它們的 SpanId 不同,表示這兩條日誌屬於不同的 Span。
  3. Metrics 並沒有記錄 TraceId 和 SpanId,但和 Tracing、Logging 的 Resource 是一樣的,表示它們屬於同一個應用程序。通過 Resource 和 記錄 Metrics 的時間,可以和 Tracing、Logging 的數據關聯起來。
Resource associated with Metric:
    service.name: MyCompany.MyProduct.MyService
    service.version: 1.0.0
    service.instance.id: 9f8306cb-c4a6-42f9-8d5b-897ba7f5df72
    telemetry.sdk.name: opentelemetry
    telemetry.sdk.language: dotnet
    telemetry.sdk.version: 1.6.0

Export MyCounter, Meter: Meter1/1.0.0
(2023-09-25T13:38:33.6109280Z, 2023-09-25T13:38:33.6342240Z] LongSum
Value: 100

下期預告

下期將介紹如何在 ASP.NET Core 應用程序中使用 OpenTelemetry,並使用 Elastic APM 來收集數據。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章