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**!此设置使您能够根据最适合您业务的时间线,缓慢而轻松地将应用程序迁移到使用 OTel 和 Elastic。

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

我们用于本博文的应用程序称为 Elastiflix,一个流媒体电影应用程序。它由几个用 .NET、NodeJS、Go 和 Python 编写的微服务组成。

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

Elastic 可观测性的所有 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 对象,例如连接到 Elastic APM 服务器或 OpenTelemetry 收集器的 url 和标头。

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 处理器是一个接口,允许在 span 开始和结束方法调用时挂钩。

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

此外,我们添加了资源模块。这使我们能够指定属性,例如 service.name、version 等。有关更多详细信息,请参阅 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,
});

最后一步,我们将获取跟踪程序的实例,以便我们可以使用它来创建自定义跨度。

const tracer = opentelemetry.trace.getTracer();

步骤 2. 添加自定义跨度

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

我们的示例应用程序有一个 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 });
});

附加代码
除了模块和跨度检测外,示例应用程序在启动时还会检查一些环境变量。在没有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导出器需要发送数据到的端点和身份验证信息,以及一些其他环境变量。

获取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中看到以下输出

注意这如何反映自动检测的版本。

值得吗?

这是个百万美元的问题。根据您需要的详细程度,手动检测可能是必要的。手动检测允许您在需要的地方添加自定义跨度、自定义标签和指标。它允许您获得否则无法获得的详细程度,并且通常对于跟踪特定于业务的KPI非常重要。

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

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

结论

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

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

希望这能提供一个易于理解的关于使用OpenTelemetry检测Node.js以及将其跟踪发送到Elastic有多么简单的分步指南。

开发者资源

通用配置和用例资源

还没有Elastic Cloud帐户?注册Elastic Cloud并试用我上面讨论的自动检测功能。我很想知道您在使用Elastic获得应用程序堆栈可见性方面的体验反馈。

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