iT邦幫忙

2024 iThome 鐵人賽

DAY 19
0
Software Development

用 NestJS 闖蕩微服務!系列 第 19

[用NestJS闖蕩微服務!] DAY19 - OpenTelemetry (下)

  • 分享至 

  • xImage
  •  

NestJS 與 OpenTelemetry

OpenTelemetry 有提供 Node.js 的 Client SDK,讓 Node.js 開發者可以透過其產生、匯出 Telemetry,那麼 NestJS 該如何與該 SDK 搭配使用呢?有官方套件可以使用嗎?很遺憾的是,NestJS 官方並 沒有 提供 NestJS 專用的套件,但 OpenTelemetry 有替 NestJS 製作了專屬的套件來收集 NestJS 獨有的資訊。

安裝 SDK

OpenTelemetry 將各個不同職責的功能拆分成多個函式庫,可以根據自身需求決定要使用哪些功能。透過下方指令安裝 OpenTelemetry SDK 核心套件:

$ npm install @opentelemetry/sdk-node

上一篇我們透過 Grafana Tempo 的範例快速啟動了 OpenTelemetry Collector,該 Collector 有設定 gRPC OTLP 的 Receiver,所以我們可以透過下方指令安裝 gRPC OTLP 的 Exporter,以便將 Trace 資料匯出至 Collector:

$ npm install @opentelemetry/exporter-trace-otlp-grpc

OpenTelemetry 所產生的 Span 可以帶有 資源(Resource) Attributes,用來標示此 Span 產生時的實體資源,比如:服務名稱、Kubernetes Pod 名稱等,這在觀察特定實體資源的行為會很有幫助。透過下方指令安裝產生 Resource 的函式庫,以便將實體資源相關資訊帶入 Span 中:

$ npm install @opentelemetry/resources

在產生 Resource Attributes 的時候,可以參考 OpenTelemetry 定義的Resource Semantic Conventions,裡面詳細描述了 Resource 的標準 Attribute 用法與名稱定義,當然,OpenTelemetry 有為此另外準備了函式庫給開發者使用:

$ npm install @opentelemetry/semantic-conventions

補充:事實上,@opentelemetry/resources 本身的依賴項目就包含了 @opentelemetry/semantic-conventions

在開發階段可能會需要偵錯的手段,這裡可以透過下方函式庫提供的功能將 OpenTelemetry SDK 所產生的 Log 印出:

$ npm install @opentelemetry/api

有了基礎建設所需的項目後,就需要透過 OpenTelemetry SDK 相關機制來收集資訊,而這套機制就叫 檢測器(Instrumentation)。在收集資訊方面,可以根據不同面向來使用不同的 Instrumentation,舉例來說,我們想要知道一個請求從 NestJS Controller 進入後到整個請求結束時各個 function 執行的狀況,就可以透過下方指令安裝 NestJS 專用函式庫:

$ npm install @opentelemetry/instrumentation-nestjs-core

如果要知道一個 HTTP 請求的相關資訊並讓 OpenTelemetry 自動為 HTTP 相關操作執行 Context Propagation,可以安裝下方套件:

$ npm install @opentelemetry/instrumentation-http

使用 SDK

由於 OpenTelemetry SDK 會盡可能地捕捉所需的資訊,會需要 在載入任何其他模組之前將 SDK 初始化,否則有可能會得到非預期的結果。以 NestJS 應用程式來說,會需要在執行 main.ts 的最前面初始化 SDK,這裡我們可以新增一個名為 tracer.ts 的檔案,並執行一系列初始化 SDK 的工作。下方為範例程式碼,裡面有針對部分程式碼撰寫註解:

import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';

/**
 * 產生 OpenTelemetry SDK 實例
 */
function generateTracer() {
  const traceExporter = new OTLPTraceExporter({
    url: 'http://localhost:4317',  // OpenTelemetry Collector OTLP gRPC Receiver 的 Endpoint
  });

  return new NodeSDK({
    // 建立 Resource Attribute,運用 Semantic Conventions 提供的常數當作 Attribute Name
    resource: new Resource({
      [ATTR_SERVICE_NAME]: process.env['SERVICE_NAME'],
    }),
    // 設定 Trace 功能的 Exporter
    traceExporter: traceExporter,
    // 設定 NestJS Instrumentation 來產生 NestJS 相關資訊,同時設定 Http Instrumentation 自動為 HTTP 操作建立相關資訊以及實現 Context Propagation
    instrumentations: [
      new NestInstrumentation(),
      new HttpInstrumentation(),
    ],
  });
}

/**
 * 啟動 SDK 
 */
function bootstrapTracer(sdk: NodeSDK) {
  // 註冊 Logger,並指定輸出 Debug 以上的 Log
  diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
  sdk.start();
}

/**
 * 主程序
 */
function main() {
  const sdk = generateTracer();
  bootstrapTracer(sdk);

  // 在程式關閉之前 Graceful Shutdown SDK
  process.on('SIGTERM', () => {
    sdk
      .shutdown()
      .then(() => console.log('SDK shutdown successfully.'))
      .catch((error) => console.log('Error shutdown SDK.', error))
      .finally(() => process.exit(0));
  });
}

main();

最後,到 main.ts第一行 匯入該檔案:

import './tracer';
// ...

整合 Collector 與 Grafana Tempo

先規劃一下這次整合會使用到的服務,預計會產生 service-aservice-bservice-c,由 service-a 擔任請求進入點,它會向 service-b 請求訂單資訊,收到訂單資訊後會將訂單資訊中的商品 ID 帶給 service-c 取得商品資訊,最終再將兩個資訊合併起來回傳給使用者。架構如下圖所示:

Integrate Trace Architecture

注意:接下來實作 service-aservice-bservice-c 請在 main.ts 匯入 tracer.ts,假如是建立三個 NestJS Application,那就將 tracer.ts 複製到這三個 Application 的 codebase 中。

實作 service-a

針對 service-a 的部分進行調整,由於該服務會需要透過 HTTP 存取 service-bservice-c,所以需要使用到 HttpModule。下方是範例程式碼,在 AppModule 匯入 HttpModule

import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
// ...

@Module({
  imports: [HttpModule],
  // ...
})
export class AppModule {}

補充:此處的內容涉及 HTTPModule 相關知識,可以參考官方文件或是我之前分享的文章

接著,調整 AppController 的內容,設計使用 GET 方法存取 /views/orders/:orderId 的 API,會使用 orderId 傳給 service-b,等收到回應後再將 productId 傳給 service-c,最後再將資訊整合起來回傳給客戶端:

import { Controller, Get, Param } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { concatMap, map, of } from 'rxjs';

type Order =
  | {
      id: string;
      state: string;
      productId: string;
    }
  | Record<string, never>;

type Product =
  | {
      id: string;
      name: string;
    }
  | Record<string, never>;

@Controller()
export class AppController {
  constructor(private readonly httpService: HttpService) {}

  @Get('views/orders/:orderId')
  getOrderView(@Param('orderId') orderId: string) {
    const orderInfo$ = this.httpService
      .get<Order>(`http://localhost:3334/orders/${orderId}`)
      .pipe(map((res) => res.data));
    return orderInfo$.pipe(
      concatMap((order) => {
        if (!order.id) {
          return of({});
        }
        return this.httpService
          .get<Product>(`http://localhost:3335/products/${order.productId}`)
          .pipe(
            map((res) => res.data),
            map((product) => ({ id: order.id, state: order.state, product }))
          );
      })
    );
  }
}

注意:啟動時,請記得指定環境變數 PORT3333,同時將 SERVICE_NAME 設定為 nestjs-service-a,避免與其他兩個服務衝突。

實作 service-b

修改 AppController 的內容,設計使用 GET 方法存取 /orders/:orderId 的 API:

import { Controller, Get, Param } from '@nestjs/common';

@Controller()
export class AppController {
  private readonly orders = [
    {
      id: '1',
      state: 'pending',
      productId: 'a',
    },
  ];

  @Get('orders/:orderId')
  getOrderById(@Param('orderId') orderId: string) {
    return this.orders.find((order) => order.id === orderId) ?? {};
  }
}

注意:啟動時,請記得指定環境變數 PORT3334,同時將 SERVICE_NAME 設定為 nestjs-service-b,避免與其他兩個服務衝突。

實作 service-c

修改 AppController 的內容,設計使用 GET 方法存取 /products/:productId 的 API:

import { Controller, Get, Param } from '@nestjs/common';

@Controller()
export class AppController {
  private readonly products = [
    {
      id: 'a',
      name: 'Book',
    },
  ];

  @Get('products/:productId')
  getProductById(@Param('productId') productId: string) {
    return this.products.find((product) => product.id === productId) ?? {};
  }
}

注意:啟動時,請記得指定環境變數 PORT3335,同時將 SERVICE_NAME 設定為 nestjs-service-c,避免與其他兩個服務衝突。

實測結果

根據前一篇的內容將 OpenTelemetry Collector、Grafana Tempo 與 Grafana 架設起來:

$ docker-compose up -d

使用 Postman 透過 GET 方法存取 http://localhost:3333/views/orders/1,會順利收到組合後的結果:

Order View Result

透過瀏覽器存取 http://localhost:3000 開啟 Grafana,並進入「Explore」頁面,在「Search」狀態下查詢「Service Name」為「nestjs-service-a」可以看到剛才存取 API 時產生的 Trace:

Order View Trace Result1

點開來可以看到詳細的 Trace 過程:

Order View Trace Result2

小結

在本篇文章中,介紹了如何在 NestJS 應用程式中整合 OpenTelemetry,首先,說明了多個 OpenTelemetry 函式庫的作用,包含: @opentelemetry/sdk-node@opentelemetry/exporter-trace-otlp-grpc@opentelemetry/resources 等。接著,撰寫 tracer.ts 來初始化 OpenTelemetry SDK,透過 @opentelemetry/instrumentation-http@opentelemetry/instrumentation-nestjs-core 來自動收集 HTTP 請求與 NestJS 相關資訊。最後,實作了三個服務來模擬 Trace 的過程,並運用上一篇介紹的 OpenTelemetry Collector、Grafana Tempo 與 Grafana 來實現 Trace 的查詢與分析。

服務可觀測性的部分到此告一段落,這幾天充分學習到 Metrics、Log 與 Trace 在 NestJS 應用程式中要如何實現以及該如何與熱門的工具做整合。下一篇文章我們將進入 微服務的管理策略篇,敬請期待!


上一篇
[用NestJS闖蕩微服務!] DAY18 - OpenTelemetry (上)
下一篇
[用NestJS闖蕩微服務!] DAY20 - Monorepo (上)
系列文
用 NestJS 闖蕩微服務!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言