跳到主要内容
Deno 2.4 现已推出,包含 deno bundle、字节/文本导入、OTel 稳定版等
了解更多

使用 Deno 和 OpenTelemetry 实现零配置调试

生产环境中的调试可能比本地开发更困难、更复杂——原因包括由于缺乏逐步调试而导致可见性受限,需要跨分布式系统进行追踪等等。但幸运的是,您可以立即采取行动来提升您的调试水平。

Old man yells at cloud

调试生产环境中的问题。

在讨论如何改进调试之前,什么才是“好的”调试?好的调试需要好的数据。为了帮助快速理解问题,您应该知道:

  • 发生了什么?
  • 谁触发了它?
  • 花了多长时间?
  • 它何时开始?
  • 它是哪个请求的一部分?

在这篇博客文章中,我们将介绍可观测性的三大支柱——日志、追踪和指标——以及它们如何协同工作,在调试时回答重要问题,从而帮助您实际解决真实问题。我们还将展示如何在 Deno 中运行现有服务器端代码,无需任何额外代码即可进行插桩。

或者观看包含本文所有演示的20分钟视频。

日志的问题

我们经常看到这样的日志

[http] GET /api/book/1
[http] GET /api/book/2
[db] SELECT * FROM books WHERE id = 1
[db] SELECT * FROM books WHERE id = 2
error: Uncaught (in promise) PostgresError: 'name' column is null
  throw new PostgresError(`'name' column is null`);

    at query (file:///tmp/main.ts:29:9)
    at queryBook (file:///tmp/main.ts:25:3)
    at handleRequest (file:///tmp/main.ts:21:3)
    at file:///tmp/main.ts:1​8:1

是图书1还是图书2的 `name` 字段为 `null`?🤔

没有上下文,你无法判断,因为这里混合了来自两个不同并行请求的日志。

然而,有一个解决办法:如果我们为每个日志关联一个请求 ID,那么我们就可以确定哪本书的 `name` 字段是 `null`

[req:1] [http] GET /api/book/1
[req:2] [http] GET /api/book/2
[req:1] [db] SELECT * FROM books WHERE id = 1
[req:2] [db] SELECT * FROM books WHERE id = 2
[req:1] error: Uncaught (in promise) PostgresError: 'name' column is null
  throw new PostgresError(`'name' column is null`);

    at query (file:///tmp/main.ts:29:9)
    at queryBook (file:///tmp/main.ts:25:3)
    at handleRequest (file:///tmp/main.ts:21:3)
    at file:///tmp/main.ts:1​8:1

现在,我们能够立即理解导致错误的问题:图书1缺少名称。

但增强我们的日志记录以包含唯一请求 ID 可能很麻烦且难以维护。在我们的代码中,我们必须生成 `requestId` 并将其传递给所有可能 `console.log` 的下游函数

 // ...

 app.get("/api/book/:id", async (req, res) => {
+  const requestId = Math.random();
+  console.log(`[req:${requestId}] [http] GET /api/book/${req.params.id}`);
-  console.log(`[http] GET /api/book/${req.params.id}`);
   const id = req.params.id;
   const book = await getBookFromDatabase(requestId, id);
   res.send(book);
 });

+ async function getBookFromDatabase(requestId: number, bookId: string) {
+   console.log(`[req:${requestId}] [db] getBookFromDatabase ${bookId}`);
-   console.log(`[db] getBookFromDatabase ${bookId}`);
    const book = books.get(bookId);
    if (book === undefined) throw new Error("No book found.");
    if (!book.author) throw new Error("Book is missing author.");
    return book;
  },
为了将日志与单个请求关联起来,我们必须传递一个唯一的 `requestId`,并将其包含在每个 `console.log` 中,这使得代码难以维护和扩展。

这正是 Deno 内置 OpenTelemetry 支持的用武之地——您只需使用 Deno 运行程序,所有 `console.log` 将自动关联唯一的请求 ID,Deno 会为每个传入的 HTTP 请求自动分配这些 ID

Luca 演示如何使用 Deno 和 OTel 自动捕获日志。

在我们的文档中了解更多关于 Deno 和 OpenTelemetry 的信息。

追踪:谁、何时、何事及持续时间

除了日志,我们经常想回答诸如以下问题:

哪个调用方向这个函数传递了错误的参数?

这两个调用点中,哪一个调用了这个数据库查询?

为了回答这个问题,我们不仅需要知道操作属于哪个请求,还需要知道父操作。这里,“操作”仅仅意味着我们想要追踪的东西,例如一个 HTTP 请求或一个数据库调用。

在遥测领域,我们正在寻找的是追踪。它们让我们知道谁触发了什么、何时触发以及耗时多久。这可以很好地以图形格式显示,如下图所示:

Example of trace

例如,这个追踪表示一个单一的 `GET` 请求,它触发了一个授权检查、两次数据库查询等等。你会注意到在这个追踪中你可以看到:

  • 哪个代码触发了操作
  • 每个操作花费了多长时间
  • 哪些操作阻塞了其他操作(例如,授权检查阻塞了数据库查询)

在这里,这让我们知道这两个数据库查询花费的时间最长。

在一次追踪中,每个操作被称为一个跨度 (span)。一个跨度记录了操作持续了多长时间、何时开始、谁触发了它以及它属于哪个追踪。

为了使每个跨度在其父级追踪中正确嵌套,我们需要一种方法来追踪父级。这可以通过为每个操作分配一个跨度 ID 来完成,当我们开始一个新操作(跨度)时,我们可以找出当前的跨度 ID 并将其用作父跨度。

Parent operations and their spans

子操作(数据库调用)与父操作(HTTP请求)的跨度 ID 共享相同的父跨度 ID。

Deno 开箱即用地为每个传入的 HTTP 请求自动创建一个 span。这意味着,如果您使用 Deno 运行您的服务器,您将为每个传入的 HTTP 请求获得一个 trace,并且每个 trace 都将包含一个请求的 span。这是自动完成的,您无需进行任何配置。

在一个简单的 Web 应用中,这意味着您将为每个 HTTP 请求获得一个包含单个 span 的 trace

An example of a trace with spans

Deno 还会自动为传出的 HTTP 请求创建 span。这意味着,如果您发出一个传出的 HTTP 请求,Deno 会自动为该请求创建一个 span,并且它将是传入 HTTP 请求的 span 的子级。

但是我们可以通过添加更多的 span 来进一步改进我们的追踪,例如针对数据库操作。我们可以通过在进行数据库调用之前开始一个 span,并在数据库调用完成后结束它来实现。为了简化这一点,我喜欢使用一个包装 OpenTelemetry API 中 `startActiveSpan` 函数的小型工具函数。这个工具函数会自动为我们处理 span 的生命周期,包括设置状态码和记录异常。

util.ts
import { Span, SpanStatusCode, trace } from "@opentelemetry/api";

const tracer = trace.getTracer("api");

export function withSpan<Args extends unknown[], T>(
  name: string,
  cb: (span: Span, ...args: Args) => Promise<T>,
): (...args: Args) => Promise<T> {
  return (...args: Args) => {
    return tracer.startActiveSpan(name, async (span) => {
      try {
        return await cb(span, ...args);
      } catch (err) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
        span.recordException(err as Error);
        throw err;
      } finally {
        span.end();
      }
    });
  };
}

然后我们可以使用这个工具函数来包装我们的 `getBookFromDatabase` 函数

db.ts
import { withSpan } from "./utils.ts";

export const getBookFromDatabase = withSpan(
  "getBookFromDatabase",
  async function getBookFromDatabase(span, bookId: string) {
    span.setAttribute("book_id", bookId);
    console.log(`[db] getBookFromDatabase ${bookId}`);
    await new Promise((resolve) => setTimeout(resolve, 300));
    const book = books.get(bookId);
    if (book === undefined) throw new Error("No book found.");
    if (!book.author) throw new Error("Book is missing author.");
    return book;
  },
);

现在,当我们再次发送 `cURL` 并查看该请求的追踪时,我们可以看到两个 span

Multiple spans in a trace

我们现在能够追踪由单个 HTTP 请求触发的所有操作。

这是一个端到端演示

Luca 展示了 Deno 和 OTel 自动捕获的追踪。

跨多个服务的追踪

在处理多个服务(例如微服务系统)时,调试可能会变得特别困难。不过,追踪在这里也能有所帮助,因为追踪可以跨多个服务。所有这一切都要求您在发出请求时将“父操作”信息从一个服务传播到另一个服务。这被称为传播

使用 W3C 规范 `tracecontext` 可以简化这一点,它是一种标准格式,用于表示要在服务之间传播的追踪信息。

你*可以*在发送端用一个这样的函数自己实现,手动将头部注入请求中

import { trace } from "npm:@opentelemetry/api";

export function fetchWithTrace(req: Request): Promise<Response> {
  const span = trace.getActiveSpan();
  if (!span) return fetch(req);
  const spanContext = span.spanContext();
  const traceparent = `00-${spanContext.traceId}-${spanContext.spanId}-01`;
  req.headers.set("traceparent", traceparent);
  return fetch(req);
}

// every call to `fetch` now needs to be replaced with `fetchWithTrace`
const resp = await fetchWithTrace(new Request("https://example.com"));

你还必须在接收端读取 `traceparent` 头,并使用 `traceparent` 信息创建一个新的 span。这工作量很大,而且很容易出错。你还必须记住对每个出站请求和每个入站请求都这样做。

有了 Deno,你实际上不必做任何这些。Deno 会自动为你传播服务之间的追踪信息。它可以自动将追踪信息注入到出站请求中,并且可以自动从入站请求中读取追踪信息。

让我们创建一个新的 `main.ts`,它只调用 API 服务器

const resp = await fetch("https://localhost:8000/api/book/1");
const book = await resp.json();
console.log(book);

现在,启用 OTEL 运行它

$ deno run -A --unstable-otel main.ts
{ id: "1", title: "The Great Gatsby", author: "F. Scott Fitzgerald" }

当我们检查 Grafana 的追踪时,你会注意到最新的追踪没有服务名称 `book-api`。那是因为追踪源自一个新服务。

A trace across multiple services

点击那个 `fetcher` 追踪会显示三个 span:一个绿色和两个紫色。Deno 已经自动跟踪了跨多个服务的单一追踪。

A trace across multiple services

对于更复杂的微服务架构,您可以想象跨多个服务有深度嵌套的 span。

这是 Luca 的一个完整演示,展示了 Deno 如何自动收集跨多个服务的追踪

Luca 演示了 Deno 和 OTel 自动收集的跨多个服务的追踪。

指标:何时调查问题?

您可能不想等到客户遇到问题才让您知道需要修复。在调查生产问题时,超越客户的最佳方法是通过指标(或更具体地说,通过指标设置警报阈值)。

指标是聚合数据,显示某个事件发生的频率。例如,常见的设置是当有一定百分比的请求返回 500 状态码时(例如 1% 或 0.1%)触发警报。

An example of metrics of requests with various status codes

各种状态码请求指标的示例。

使用 Deno 运行您的程序将自动收集指标

Luca 演示了 Deno 和 OTel 自动捕获的指标。

下一步

在这篇文章中,我们介绍了 OpenTelemetry 的三大支柱

  • 日志:发生了什么,何时发生,以及它属于哪个请求?
  • 追踪:发生了什么,谁触发了它,何时发生,耗时多久,以及它属于哪个请求?
  • 指标:某件事发生的频率如何?

我们还展示了 Deno 内置的 OpenTelemetry 支持将自动收集所有这些信息,无需任何额外的实现或配置。(您还可以使用 Deno 运行您的 Node 项目,并自动获得此级别的可观测性。)在 Deno 中运行您的程序时,您将获得:

  • 日志与追踪ID关联
  • 每个 HTTP 请求一个追踪
  • 传入和传出 HTTP 请求的 span
  • 请求计数指标(按状态码)
  • 响应延迟(按状态码)
  • 自动传播

还有更多功能即将推出。

Deno 让编写代码变得简单。但我们知道编写代码只是成功的一半,另一半是调试和维护代码。这就是为什么调试,特别是在生产环境中的调试,现在成为 Deno 运行时如此核心的一部分。

🚨️ 了解更多关于 Deno 2.2 的信息 🚨️

等等!