使用 Deno 和 OpenTelemetry 实现零配置调试
生产环境中的调试可能比本地开发更困难、更复杂——原因包括由于缺乏逐步调试而导致可见性受限,需要跨分布式系统进行追踪等等。但幸运的是,您可以立即采取行动来提升您的调试水平。
在讨论如何改进调试之前,什么才是“好的”调试?好的调试需要好的数据。为了帮助快速理解问题,您应该知道:
- 发生了什么?
- 谁触发了它?
- 花了多长时间?
- 它何时开始?
- 它是哪个请求的一部分?
在这篇博客文章中,我们将介绍可观测性的三大支柱——日志、追踪和指标——以及它们如何协同工作,在调试时回答重要问题,从而帮助您实际解决真实问题。我们还将展示如何在 Deno 中运行现有服务器端代码,无需任何额外代码即可进行插桩。
日志的问题
我们经常看到这样的日志
[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:18: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:18: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;
},
这正是 Deno 内置 OpenTelemetry 支持的用武之地——您只需使用 Deno 运行程序,所有 `console.log` 将自动关联唯一的请求 ID,Deno 会为每个传入的 HTTP 请求自动分配这些 ID
在我们的文档中了解更多关于 Deno 和 OpenTelemetry 的信息。
追踪:谁、何时、何事及持续时间
除了日志,我们经常想回答诸如以下问题:
哪个调用方向这个函数传递了错误的参数?
这两个调用点中,哪一个调用了这个数据库查询?
为了回答这个问题,我们不仅需要知道操作属于哪个请求,还需要知道父操作。这里,“操作”仅仅意味着我们想要追踪的东西,例如一个 HTTP 请求或一个数据库调用。
在遥测领域,我们正在寻找的是追踪。它们让我们知道谁触发了什么、何时触发以及耗时多久。这可以很好地以图形格式显示,如下图所示:
例如,这个追踪表示一个单一的 `GET` 请求,它触发了一个授权检查、两次数据库查询等等。你会注意到在这个追踪中你可以看到:
- 哪个代码触发了操作
- 每个操作花费了多长时间
- 哪些操作阻塞了其他操作(例如,授权检查阻塞了数据库查询)
在这里,这让我们知道这两个数据库查询花费的时间最长。
在一次追踪中,每个操作被称为一个跨度 (span)。一个跨度记录了操作持续了多长时间、何时开始、谁触发了它以及它属于哪个追踪。
为了使每个跨度在其父级追踪中正确嵌套,我们需要一种方法来追踪父级。这可以通过为每个操作分配一个跨度 ID 来完成,当我们开始一个新操作(跨度)时,我们可以找出当前的跨度 ID 并将其用作父跨度。
Deno 开箱即用地为每个传入的 HTTP 请求自动创建一个 span。这意味着,如果您使用 Deno 运行您的服务器,您将为每个传入的 HTTP 请求获得一个 trace,并且每个 trace 都将包含一个请求的 span。这是自动完成的,您无需进行任何配置。
在一个简单的 Web 应用中,这意味着您将为每个 HTTP 请求获得一个包含单个 span 的 trace
Deno 还会自动为传出的 HTTP 请求创建 span。这意味着,如果您发出一个传出的 HTTP 请求,Deno 会自动为该请求创建一个 span,并且它将是传入 HTTP 请求的 span 的子级。
但是我们可以通过添加更多的 span 来进一步改进我们的追踪,例如针对数据库操作。我们可以通过在进行数据库调用之前开始一个 span,并在数据库调用完成后结束它来实现。为了简化这一点,我喜欢使用一个包装 OpenTelemetry API 中 `startActiveSpan` 函数的小型工具函数。这个工具函数会自动为我们处理 span 的生命周期,包括设置状态码和记录异常。
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` 函数
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
我们现在能够追踪由单个 HTTP 请求触发的所有操作。
这是一个端到端演示
跨多个服务的追踪
在处理多个服务(例如微服务系统)时,调试可能会变得特别困难。不过,追踪在这里也能有所帮助,因为追踪可以跨多个服务。所有这一切都要求您在发出请求时将“父操作”信息从一个服务传播到另一个服务。这被称为传播。
使用 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`。那是因为追踪源自一个新服务。
点击那个 `fetcher` 追踪会显示三个 span:一个绿色和两个紫色。Deno 已经自动跟踪了跨多个服务的单一追踪。
对于更复杂的微服务架构,您可以想象跨多个服务有深度嵌套的 span。
这是 Luca 的一个完整演示,展示了 Deno 如何自动收集跨多个服务的追踪
指标:何时调查问题?
您可能不想等到客户遇到问题才让您知道需要修复。在调查生产问题时,超越客户的最佳方法是通过指标(或更具体地说,通过指标设置警报阈值)。
指标是聚合数据,显示某个事件发生的频率。例如,常见的设置是当有一定百分比的请求返回 500 状态码时(例如 1% 或 0.1%)触发警报。
使用 Deno 运行您的程序将自动收集指标
下一步
在这篇文章中,我们介绍了 OpenTelemetry 的三大支柱
- 日志:发生了什么,何时发生,以及它属于哪个请求?
- 追踪:发生了什么,谁触发了它,何时发生,耗时多久,以及它属于哪个请求?
- 指标:某件事发生的频率如何?
我们还展示了 Deno 内置的 OpenTelemetry 支持将自动收集所有这些信息,无需任何额外的实现或配置。(您还可以使用 Deno 运行您的 Node 项目,并自动获得此级别的可观测性。)在 Deno 中运行您的程序时,您将获得:
- 日志与追踪ID关联
- 每个 HTTP 请求一个追踪
- 传入和传出 HTTP 请求的 span
- 请求计数指标(按状态码)
- 响应延迟(按状态码)
- 自动传播
还有更多功能即将推出。
Deno 让编写代码变得简单。但我们知道编写代码只是成功的一半,另一半是调试和维护代码。这就是为什么调试,特别是在生产环境中的调试,现在成为 Deno 运行时如此核心的一部分。
🚨️ 了解更多关于 Deno 2.2 的信息 🚨️
等等!