Bahubali Shetti

使用 OpenTelemetry 手动检测 Node.js 应用程序

在这篇博文中,我们将向您展示如何使用 OpenTelemetry 手动检测 Node.js 应用程序。我们将探讨如何使用正确的 OpenTelemetry Node.js 库,并特别关注在 Node.js 应用程序中检测追踪。

阅读时间 18 分钟
Manual instrumentation with OpenTelemetry for Node.js applications

DevOps 和 SRE 团队正在改变软件开发流程。DevOps 工程师专注于高效的软件应用程序和服务交付,而 SRE 团队则是确保可靠性、可扩展性和性能的关键。这些团队必须依赖全栈可观测性解决方案,以便管理和监控系统,并确保在问题影响业务之前解决问题。

对现代分布式应用程序的整个堆栈进行可观测性,通常需要以仪表板的形式进行数据收集、处理和关联。采集所有系统数据需要在各个堆栈、框架和提供商中安装代理,对于必须处理版本更改、兼容性问题以及无法随着系统更改而扩展的专有代码的团队来说,这个过程可能具有挑战性且耗时。

得益于 OpenTelemetry (OTel),DevOps 和 SRE 团队现在有了一种收集和发送数据的标准方法,该方法不依赖于专有代码,并且拥有庞大的支持社区,减少了供应商锁定。

之前的博客中,我们还回顾了如何使用 OpenTelemetry 演示并将其连接到 Elastic®,以及 Elastic 在 OpenTelemetry 和 Kubernetes 方面的一些功能。

在本博客中,我们将展示如何使用 OpenTelemetry 的手动检测,以及我们名为 Elastiflix 的应用程序的 Node.js 服务。这种方法比使用自动检测稍微复杂一些。

它的美妙之处在于无需 otel-collector!此设置使您能够根据最适合您业务的时间表,缓慢且轻松地将应用程序迁移到带有 Elastic 的 OTel。

应用程序、先决条件和配置

我们在此博客中使用的应用程序名为 Elastiflix,这是一个电影流媒体应用程序。它由用 .NET、NodeJS、Go 和 Python 编写的多个微服务组成。

在检测我们的示例应用程序之前,我们首先需要了解 Elastic 如何接收遥测数据。

Elastic Observability 的所有 APM 功能都可通过 OTel 数据获得。其中包括:

  • 服务地图
  • 服务详细信息(延迟、吞吐量、失败的事务)
  • 服务之间的依赖关系,分布式跟踪
  • 事务(跟踪)
  • 机器学习 (ML) 相关性
  • 日志相关性

除了 Elastic 的 APM 和遥测数据的统一视图外,您还可以使用 Elastic 强大的机器学习功能来减少分析和警报,从而帮助减少 MTTR。

先决条件

查看示例源代码

完整的源代码(包括本博客中使用的 Dockerfile)可以在 GitHub 上找到。该存储库还包含没有检测的相同应用程序。这使您可以比较每个文件并查看差异。

在我们开始之前,让我们先看一下未检测的代码。

这是我们简单的 index.js 文件,它可以接收 POST 请求。请参阅此处的完整代码。

const pino = require("pino");
const ecsFormat = require("@elastic/ecs-pino-format"); //
const log = pino({ ...ecsFormat({ convertReqRes: true }) });
const expressPino = require("express-pino-logger")({ logger: log });

var API_ENDPOINT_FAVORITES =
  process.env.API_ENDPOINT_FAVORITES || "127.0.0.1:5000";
API_ENDPOINT_FAVORITES = API_ENDPOINT_FAVORITES.split(",");

const express = require("express");
const cors = require("cors")({ origin: true });
const cookieParser = require("cookie-parser");
const { json } = require("body-parser");

const PORT = process.env.PORT || 3001;

const app = express().use(cookieParser(), cors, json(), expressPino);

const axios = require("axios");

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((err, req, res, next) => {
  log.error(err.stack);
  res.status(500).json({ error: err.message, code: err.code });
});

var favorites = {};

app.post("/api/favorites", (req, res) => {
  var randomIndex = Math.floor(Math.random() * API_ENDPOINT_FAVORITES.length);
  if (process.env.THROW_NOT_A_FUNCTION_ERROR == "true" && Math.random() < 0.5) {
    // randomly choose one of the endpoints
    axios
      .post(
        "http://" +
          API_ENDPOINT_FAVORITES[randomIndex] +
          "/favorites?user_id=1",
        req.body
      )
      .then(function (response) {
        favorites = response.data;
        // quiz solution: "42"
        res.jsonn({ favorites: favorites });
      })
      .catch(function (error) {
        res.json({ error: error, favorites: [] });
      });
  } else {
    axios
      .post(
        "http://" +
          API_ENDPOINT_FAVORITES[randomIndex] +
          "/favorites?user_id=1",
        req.body
      )
      .then(function (response) {
        favorites = response.data;
        res.json({ favorites: favorites });
      })
      .catch(function (error) {
        res.json({ error: error, favorites: [] });
      });
  }
});

app.listen(PORT, () => {
  console.log(`Server listening on ${PORT}`);
});

分步指南

步骤 0. 登录您的 Elastic Cloud 帐户

本博客假设您拥有一个 Elastic Cloud 帐户 — 如果没有,请按照在 Elastic Cloud 上入门的说明进行操作。

步骤 1. 安装并初始化 OpenTelemetry

第一步,我们需要向应用程序添加一些额外的模块。

const opentelemetry = require("@opentelemetry/api");
const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace-base");
const { Resource } = require("@opentelemetry/resources");
const {
  SemanticResourceAttributes,
} = require("@opentelemetry/semantic-conventions");

const { registerInstrumentations } = require("@opentelemetry/instrumentation");
const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");
const {
  ExpressInstrumentation,
} = require("@opentelemetry/instrumentation-express");

我们首先创建一个 collectorOptions 对象,其中包含 url 和标头等参数,用于连接到 Elastic APM 服务器或 OpenTelemetry 收集器。

const collectorOptions = {
  url: OTEL_EXPORTER_OTLP_ENDPOINT,
  headers: OTEL_EXPORTER_OTLP_HEADERS,
};

为了将其他参数传递给 OpenTelemetry,我们将读取 OTEL_RESOURCE_ATTRIBUTES 变量并将其转换为对象。

const envAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES || "";

// Parse the environment variable string into an object
const attributes = envAttributes.split(",").reduce((acc, curr) => {
  const [key, value] = curr.split("=");
  if (key && value) {
    acc[key.trim()] = value.trim();
  }
  return acc;
}, {});

接下来,我们将使用这些参数来填充资源配置。

const resource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]:
    attributes["service.name"] || "node-server-otel-manual",
  [SemanticResourceAttributes.SERVICE_VERSION]:
    attributes["service.version"] || "1.0.0",
  [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]:
    attributes["deployment.environment"] || "production",
});

然后,我们使用先前创建的资源设置跟踪提供程序,然后设置使用之前的 collectorOptions 的导出器。跟踪提供程序将允许我们在稍后创建跨度。

此外,我们指定使用 BatchSPanProcessor。Span 处理器是一个接口,允许对跨度开始和结束方法调用进行挂钩。

在 OpenTelemetry 中,提供了不同的 Span 处理器。BatchSPanProcessor 批量处理跨度并批量发送。可以使用 MultiSpanProcessor 配置多个 Span 处理器以同时处于活动状态。请参阅 OpenTelemetry 文档

此外,我们还添加了资源模块。这允许我们指定诸如 service.name、版本等属性。有关更多详细信息,请参阅 OpenTelemetry 语义约定文档

const tracerProvider = new NodeTracerProvider({
  resource: resource,
});

const exporter = new OTLPTraceExporter(collectorOptions);
tracerProvider.addSpanProcessor(new BatchSpanProcessor(exporter));
tracerProvider.register();

接下来,我们将注册一些检测器。这将自动为我们检测 Express 和 HTTP。虽然也可以完全手动执行此步骤,但这将非常复杂并且浪费时间。通过这种方式,我们可以确保正确捕获任何传入和传出的请求,并且诸如分布式追踪之类的功能无需任何额外的工作即可工作。

registerInstrumentations({
  instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
  tracerProvider: tracerProvider,
});

最后一步,我们将获取一个可以用来创建自定义 span 的 tracer 实例。

const tracer = opentelemetry.trace.getTracer();

步骤 2. 添加自定义 span

现在我们已经添加并初始化了模块,我们可以添加自定义 span。

我们的示例应用程序有一个 POST 请求,该请求调用下游服务。如果我们想为应用程序的这部分添加额外的检测,我们只需用以下代码包装函数代码

tracer.startActiveSpan('favorites',   tracer.startActiveSpan('favorites', (span) => {...

包装的代码如下

app.post("/api/favorites", (req, res, next) => {
  tracer.startActiveSpan("favorites", (span) => {
    axios
      .post(
        "http://" + API_ENDPOINT_FAVORITES + "/favorites?user_id=1",
        req.body
      )
      .then(function (response) {
        favorites = response.data;
        span.end();
        res.jsonn({ favorites: favorites });
      })
      .catch(next);
  });
});

自动错误处理
对于自动错误处理,我们添加了一个在 Express 中使用的函数,该函数会捕获运行时发生的任何错误。

app.use((err, req, res, next) => {
  log.error(err.stack);
  span = opentelemetry.trace.getActiveSpan();
  span.recordException(error);
  span.end();
  res.status(500).json({ error: err.message, code: err.code });
});

其他代码
除了模块和 span 检测之外,示例应用程序还在启动时检查一些环境变量。在不使用 OTel 收集器的情况下向 Elastic 发送数据时,需要 OTEL_EXPORTER_OTLP_HEADERS 变量,因为它包含身份验证。OTEL_EXPORTER_OTLP_ENDPOINT 也是如此,它是我们将发送遥测数据的主机。

const OTEL_EXPORTER_OTLP_HEADERS = process.env.OTEL_EXPORTER_OTLP_HEADERS;
// error if secret token is not set
if (!OTEL_EXPORTER_OTLP_HEADERS) {
  throw new Error("OTEL_EXPORTER_OTLP_HEADERS environment variable is not set");
}

const OTEL_EXPORTER_OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
// error if server url is not set
if (!OTEL_EXPORTER_OTLP_ENDPOINT) {
  throw new Error(
    "OTEL_EXPORTER_OTLP_ENDPOINT environment variable is not set"
  );
}

最终代码
为了进行比较,这是我们示例应用程序的检测代码。您可以在 GitHub 中找到完整的源代码。

const pino = require("pino");
const ecsFormat = require("@elastic/ecs-pino-format"); //
const log = pino({ ...ecsFormat({ convertReqRes: true }) });
const expressPino = require("express-pino-logger")({ logger: log });

// Add OpenTelemetry packages
const opentelemetry = require("@opentelemetry/api");
const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace-base");
const {
  OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-grpc");
const { Resource } = require("@opentelemetry/resources");
const {
  SemanticResourceAttributes,
} = require("@opentelemetry/semantic-conventions");

const { registerInstrumentations } = require("@opentelemetry/instrumentation");

// Import OpenTelemetry instrumentations
const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");
const {
  ExpressInstrumentation,
} = require("@opentelemetry/instrumentation-express");

var API_ENDPOINT_FAVORITES =
  process.env.API_ENDPOINT_FAVORITES || "127.0.0.1:5000";
API_ENDPOINT_FAVORITES = API_ENDPOINT_FAVORITES.split(",");

const OTEL_EXPORTER_OTLP_HEADERS = process.env.OTEL_EXPORTER_OTLP_HEADERS;
// error if secret token is not set
if (!OTEL_EXPORTER_OTLP_HEADERS) {
  throw new Error("OTEL_EXPORTER_OTLP_HEADERS environment variable is not set");
}

const OTEL_EXPORTER_OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
// error if server url is not set
if (!OTEL_EXPORTER_OTLP_ENDPOINT) {
  throw new Error(
    "OTEL_EXPORTER_OTLP_ENDPOINT environment variable is not set"
  );
}

const collectorOptions = {
  // url is optional and can be omitted - default is https://127.0.0.1:4317
  // Unix domain sockets are also supported: 'unix:///path/to/socket.sock'
  url: OTEL_EXPORTER_OTLP_ENDPOINT,
  headers: OTEL_EXPORTER_OTLP_HEADERS,
};

const envAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES || "";

// Parse the environment variable string into an object
const attributes = envAttributes.split(",").reduce((acc, curr) => {
  const [key, value] = curr.split("=");
  if (key && value) {
    acc[key.trim()] = value.trim();
  }
  return acc;
}, {});

// Create and configure the resource object
const resource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]:
    attributes["service.name"] || "node-server-otel-manual",
  [SemanticResourceAttributes.SERVICE_VERSION]:
    attributes["service.version"] || "1.0.0",
  [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]:
    attributes["deployment.environment"] || "production",
});

// Create and configure the tracer provider
const tracerProvider = new NodeTracerProvider({
  resource: resource,
});
const exporter = new OTLPTraceExporter(collectorOptions);
tracerProvider.addSpanProcessor(new BatchSpanProcessor(exporter));
tracerProvider.register();

//Register instrumentations
registerInstrumentations({
  instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
  tracerProvider: tracerProvider,
});

const express = require("express");
const cors = require("cors")({ origin: true });
const cookieParser = require("cookie-parser");
const { json } = require("body-parser");

const PORT = process.env.PORT || 3001;

const app = express().use(cookieParser(), cors, json(), expressPino);

const axios = require("axios");

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((err, req, res, next) => {
  log.error(err.stack);
  span = opentelemetry.trace.getActiveSpan();
  span.recordException(error);
  span.end();
  res.status(500).json({ error: err.message, code: err.code });
});

const tracer = opentelemetry.trace.getTracer();

var favorites = {};

app.post("/api/favorites", (req, res, next) => {
  tracer.startActiveSpan("favorites", (span) => {
    var randomIndex = Math.floor(Math.random() * API_ENDPOINT_FAVORITES.length);

    if (
      process.env.THROW_NOT_A_FUNCTION_ERROR == "true" &&
      Math.random() < 0.5
    ) {
      // randomly choose one of the endpoints
      axios
        .post(
          "http://" +
            API_ENDPOINT_FAVORITES[randomIndex] +
            "/favorites?user_id=1",
          req.body
        )
        .then(function (response) {
          favorites = response.data;
          // quiz solution: "42"
          span.end();
          res.jsonn({ favorites: favorites });
        })
        .catch(next);
    } else {
      axios
        .post(
          "http://" +
            API_ENDPOINT_FAVORITES[randomIndex] +
            "/favorites?user_id=1",
          req.body
        )
        .then(function (response) {
          favorites = response.data;
          span.end();
          res.json({ favorites: favorites });
        })
        .catch(next);
    }
  });
});

app.listen(PORT, () => {
  log.info(`Server listening on ${PORT}`);
});

步骤 3. 使用环境变量运行 Docker 镜像

我们将使用环境变量并传入配置值,使其能够与 Elastic Observability 的 APM 服务器 连接。

由于 Elastic 本机接受 OTLP,我们只需要提供 OTEL Exporter 需要发送数据的端点和身份验证,以及一些其他环境变量即可。

获取 Elastic Cloud 变量
您可以从 Kibana® 中复制端点和令牌,路径如下

/app/home#/tutorial/apm
.

您需要复制以下环境变量

OTEL_EXPORTER_OTLP_ENDPOINT
OTEL_EXPORTER_OTLP_HEADERS

构建镜像

docker build -t  node-otel-manual-image .

运行镜像

docker run \
       -e OTEL_EXPORTER_OTLP_ENDPOINT="<REPLACE WITH OTEL_EXPORTER_OTLP_ENDPOINT>" \
       -e OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <REPLACE WITH TOKEN>" \
       -e OTEL_RESOURCE_ATTRIBUTES="service.version=1.0,deployment.environment=production,service.name=node-server-otel-manual" \
       -p 3001:3001 \
       node-otel-manual-image

现在,您可以发出一些请求以生成跟踪数据。请注意,这些请求预计会返回错误,因为此服务依赖于您可能未在计算机上运行的某些下游服务。

curl localhost:3001/api/login
curl localhost:3001/api/favorites

# or alternatively issue a request every second

while true; do curl "localhost:3001/api/favorites"; sleep 1; done;

步骤 4. 在 Elastic APM 中探索

现在服务已检测,在查看 Node.js 服务的事务部分时,您应该在 Elastic APM 中看到以下输出

请注意,这与自动检测的版本相同。

值得吗?

这是一个价值百万美元的问题。根据您需要的细节级别,可能需要手动检测。手动检测允许您在需要或想要的地方添加自定义 span、自定义标签和指标。它允许您获得否则不可能实现的细节级别,并且通常对于跟踪特定于业务的 KPI 很重要。

您的操作以及您是否需要排除故障或分析代码特定部分的性能将决定何时以及检测什么。但知道您可以选择手动检测是有帮助的。

如果您注意到我们尚未检测指标,那是另一篇博客的内容。我们在之前的博客中讨论了日志。

结论

在这篇博客中,我们讨论了以下内容

  • 如何使用 OpenTelemetry 手动检测 Node.js
  • 使用 Express 时需要的不同模块
  • 如何正确初始化和检测 span
  • 如何在不需要收集器的情况下轻松地从 Elastic 设置 OTLP ENDPOINT 和 OTLP HEADERS

希望这提供了一个易于理解的 Node.js 与 OpenTelemetry 检测的演练,以及如何轻松地将跟踪发送到 Elastic。

开发者资源

通用配置和用例资源

还没有 Elastic Cloud 帐户?注册 Elastic Cloud 并尝试我上面讨论的自动检测功能。我很想获得您关于在使用 Elastic 深入了解应用程序堆栈方面的体验反馈。

本文中描述的任何功能或功能的发布和时间安排均由 Elastic 自行决定。任何当前不可用的功能或功能可能无法按时交付或根本无法交付。

分享这篇文章