Seguimiento de OpenTelemetry en 200 líneas de código / Sudo Null IT News

Los desarrolladores a menudo perciben el rastreo como un misterio y OpenTelemetry no es una excepción. Puede parecer aún más complejo debido a los muchos conceptos nuevos que se encuentran en los ejemplos básicos.

Para empeorar las cosas, a medida que se crea una biblioteca de seguimiento estable y confiable, el código en sí se vuelve más complejo para tener en cuenta casos raros, admitir el trabajo en diferentes entornos y optimizar el rendimiento mientras se minimiza el impacto en las aplicaciones. Esto es especialmente notable cuando se utiliza instrumentación automatizada, que puede ajustar o cambiar “mágicamente” código que no estaba diseñado originalmente para ello.

No sorprende que muchos desarrolladores perciban las bibliotecas de rastreo como “cajas negras”. Los agregamos a las aplicaciones, esperamos lo mejor y confiamos en ellos en momentos críticos, como durante incidentes nocturnos.

De hecho, el rastreo es mucho más sencillo de lo que parece. Si lo desglosas, puedes considerarlo como una combinación de “registro sofisticado” y “propagación del contexto”.

Registros

Los desarrolladores suelen sentirse muy cómodos trabajando con registros. Empezamos con “¡Hola mundo!” y permanecen con nosotros durante todo nuestro trabajo. Cuando surge un problema, a menudo recurrimos a los registros y agregamos algo como console.log("potato")para comprobar si el código se está ejecutando (incluso cuando el depurador está a mano).

¡Los registros son realmente útiles! Sin embargo, espero que algún día alguien lo convenza de almacenarlos en un formato clave-valor para un análisis más estructurado y conveniente.

Si hace una resolución de Año Nuevo relacionada con modernizar su infraestructura y prepararse para el nuevo y valiente mundo de los sistemas distribuidos, le sugiero esto:

Estructura tus registros.

No te arrepentirás. Eventualmente estarás muy feliz de haberlo hecho.

Hazlo.

Es una buena idea tener al menos cierta coherencia en los registros: es importante asegurarse de que cada registro tenga una marca de tiempo generada de la misma manera o tenga un campo de “nombre” para facilitar la búsqueda. Lo más probable es que ya hayas escrito funciones similares en tus proyectos:

log("user-authenticated", { userId, remaingingRateLimit });

// ...

function log(name, attributes = {}) {
  console.log(
    JSON.format({
      name,
      timestamp: new Date().getTime(),
      ...attributes,
    })
  );
}
{ "timestamp": 1725845013447, "name": "user-authenticated", "userId": "1234", "remaingingRateLimit": 100 }

También puedes escribir código como este para realizar un seguimiento del tiempo de ejecución de una subtarea:

logTiming("query-user-info", () => {
  db.fetchUserInfo();
});

// ....

function logTiming(name, attributes = {}, lambda) {
  let startTime = new Date().getTime();

  // run some subtask
  lambda();

  let durationMs = new Date().getTime() - startTime;
  log(name, { durationMs, ...attributes });
}
{ "timestamp": 1725845013447, "name": "query-user-info", "durationMs": 12 }

Si es así, ¡felicidades! Estás a medio camino de reinventar el tramo de seguimiento.

Los tramos son registros elegantes

Una traza es un conjunto de tramos. El siguiente ejemplo muestra una traza con cuatro tramos diferentes:

Span realmente puede considerarse como un conjunto de pares clave/valor, similar a las líneas de un registro, con campos obligatorios:

  • Nombre: Define la acción o evento que describe el lapso.

  • Marca de tiempo: Indica el punto inicial del tramo.

  • Duración: muestra cuánto duró el lapso.

  • Establecer ID: Esto incluye:

    • ID de seguimiento: Identificador de toda la traza, combinando todos los tramos.

    • ID de tramo: Identificador único para un intervalo específico.

    • ID de intervalo principal: ID del tramo principal (si lo hay), lo que permite la jerarquía.

Además, puede agregar cualquier atributo como pares clave/valor, por ejemplo:

let span = new Span("api-req");
let resp = await api("get-user-limits");
span.setAttributes({ responseCode: resp.code });
span.End();
console.log(span);

// ...

class Span {
  constructor(name, context = {}, attributes = new Map()) {
    this.startTime = new Date().getTime();
    this.traceID = context.traceID ?? crypto.randomBytes(16).toString("hex");
    this.parentSpanID = context.spanID ?? undefined;
    this.spanID = crypto.randomBytes(8).toString("hex");
    this.name = name;
    this.attributes = attributes;
  }

  setAttributes(keyValues) {
    for (let (key, value) of Object.entries(keyValues)) {
      this.attributes.set(key, value);
    }
  }

  end() {
    this.durationMs = new Date().getTime() - this.startTime;
  }
}

La salida de span en este formato podría verse así:

Span {
  startTime: 1722436476271,
  traceID: 'cfd3fd1ad40f008fea72e06901ff722b',
  parentSpanID: undefined,
  spanID: '6b65f0c5db08556d',
  name: 'api-req',
  attributes: Map(0) {
    "responseCode": 200
  },
  durationMs: 3903
}

La entrada de registro correspondiente al intervalo anterior podría verse así:

{
  "startTime": 1722436476271,
  "traceID": "cfd3fd1ad40f008fea72e06901ff722b",
  "spanID": "6b65f0c5db08556d",
  "name": "api-req",
  "responseCode": 200,
  "durationMs": 3903
}

Trace es un conjunto de tramos.

Si quisieras ver todos los registros de una solicitud específica, probablemente hayas hecho algo similar:

// generate a request id and inherit one from your platform
let requestID = req.headers("X-REQUEST-ID");
// ...
log("api-request-start", { requestID });
let resp = await apiRequest();
log("api-request-end", { requestID });

Lo que le permitiría buscar por ID de solicitud para ver qué sucedió cuando se procesó:

{ "timestamp": 1722436476271, "requestID": "1234", "name": "fetch-user-permissions" }
{ "timestamp": 1722436476321, "requestID": "1234", "name": "api-request-start" }
{ "timestamp": 1722436476345, "requestID": "1234", "name": "api-request-end" }
{ "timestamp": 1722436476431, "requestID": "1234", "name": "update-db-record" }
{ "timestamp": 1722436476462, "requestID": "1234", "name": "create-email-job" }

¡Hacer el rastreo de esta manera puede producir resultados bastante buenos! Pero podemos hacerlo mejor.

Un intervalo de seguimiento tiene tres ID diferentes que conforman el contexto de seguimiento. Los dos primeros son simples:

  • ID de tramo: ID aleatorio para cada tramo

  • ID de seguimiento: ID aleatorio que puede ser compartido por varios tramos

El último es un poco más complicado:

Parent Span ID permite que el sistema cree DÍA cada rastro después de recibir cada tramo. Cuando se representa como un árbol, esto nos brinda la visualización en cascada que conocemos y amamos, pero también vale la pena recordar que esta es solo una visualización de datos posible.

Contexto

El contexto solo necesita 2 valores: ID de seguimiento e ID del intervalo actual. Al crear un nuevo intervalo, podemos heredar el ID del intervalo, si lo hay, crear un nuevo ID del intervalo y crear un nuevo contexto.

let context = {
  traceID: "cfd3fd1ad40f008fea72e06901ff722b",
  spanID: "6b65f0c5db08556d",
};

Necesitamos una forma de pasar contexto en nuestra aplicación. Podríamos hacer esto manualmente:

let { span, newContext } = new Span("api-req", oldContext);

Y sí, el SDK oficial de Go realmente hace esto, pero en la mayoría de los demás idiomas esto se hace implícitamente y la biblioteca lo maneja automáticamente. En Ruby o Python puedes usar una variable local de subproceso, pero en Node usarías Almacenamiento local asíncrono.

En este punto, resulta útil envolver la creación del tramo en una función auxiliar:

import { AsyncLocalStorage } from "node:async_hooks";

let asyncLocalStorage = new AsyncLocalStorage();
// start with an empty context
asyncLocalStorage.enterWith({ traceID: undefined, spanID: undefined });

async function startSpan(name, lambda) {
  let ctx = asyncLocalStorage.getStore();
  let span = new Span(name, ctx, new Map());
  await asyncLocalStorage.run(span.getContext(), lambda, span);
  span.end();
  console.log(span);
}

startSpan("parent", async (parentSpan) => {
  parentSpan.setAttributes({ outerSpan: true });
  startSpan("child", async (childSpan) => {
    childSpan.setAttributes({ outerSpan: false });
  });
});

¡Y ahora tenemos la base de nuestra biblioteca de rastreo!

Span {
  startTime: 1725850276756,
  traceID: 'b8d002c2f6ae1291e0bd29c9791c9756',
  parentSpanID: '50f527cbf40230c3',
  name: 'child',
  attributes: Map(1) { 'outerSpan' => false },
  spanID: '8037a93b6ed25c3a',
  durationMs: 11.087375000000002
}
Span {
  startTime: 1725850276744,
  traceID: 'b8d002c2f6ae1291e0bd29c9791c9756',
  parentSpanID: undefined,
  name: 'parent',
  attributes: Map(1) { 'outerSpan' => true },
  spanID: '50f527cbf40230c3',
  durationMs: 26.076625
}

Nuestra biblioteca de rastreo es bastante viable, a pesar de tener solo 60 líneas de código, pero tiene 2 grandes inconvenientes:

  • Necesitamos agregarlo en todas partes manualmente.

  • Está limitado a un sistema. No tenemos un mecanismo para pasar contexto entre dos sistemas en un servicio.

¡Arreglemos esto!

distribuyamos

El rastreo distribuido suena aterrador, pero en general significa que el contexto de rastreo se puede pasar entre sistemas para rastrear qué operaciones llaman a otras y cómo se transfieren esos datos al destinatario final.

Cuando realizamos una solicitud HTTP a otro sistema, debemos pasar el ID de seguimiento y el ID de tramo actual. Podríamos agregar estos dos campos a la carga útil HTTP manualmente, pero hay estándar W3C para codificar esta información en un encabezado HTTP para enviarla como metadatos para cada solicitud. Título traceparent se ve así:

00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

La especificación en sí describe muchas cosas que deben entenderse e implementarse, pero para nuestros propósitos de hoy ignoraremos la mayor parte y consideraremos seguir el siguiente formato:

00-{ Trace ID}-{ Parent Span ID }-01

Nos permitirá analizar y serializar el contexto de seguimiento utilizando funciones simples:

let getTraceParent = (ctx) => `00-${ctx.traceID}-${ctx.spanID}-01`;

let parseTraceParent = (header) => ({
  traceID: header.split("-")(1),
  spanID: header.split("-")(2),
});

Necesitamos agregar esta información a las solicitudes salientes y analizarla para cualquier solicitud entrante. La instrumentación ayudará con esto.

Envolver cosas en otras cosas

Instrumentación es un término elegante para “envolver código en otro código para realizar un seguimiento de las cosas”. Las bibliotecas de seguimiento reales hacen todo lo posible para incluir las bibliotecas integradas o populares detrás de escena al configurar la biblioteca. No haremos esto.

En su lugar, crearemos middleware para el marco de Hono, que el usuario puede agregar manualmente.

async function honoMiddleware(c, next) {
  // check the incoming request for the traceparent header
  // if it exists parse and inherit the trace context
  let context = EMPTY_CONTEXT;
  if (c.req.header("traceparent")) {
    context = parseTraceParent(c.req.header("traceparent"));
  }

  // set the context and wrap the whole req / res operation in a span
  await setContext(context, async () => {
    await startSpan(`${c.req.method} ${c.req.path}`, async (span) => {
      // Before we let our app handle the request, let's pull some info about
      // it off and add it to our trace.
      span.setAttributes({
        "http.request.method": c.req.method,
        "http.request.path": c.req.path,
      });

      // let our app handle the request
      await next();

      // Pull information about how our app responded
      span.setAttributes({
        "http.response.status_code": c.res.status,
      });
    });
  });
}

También necesitamos manejar las solicitudes HTTP salientes y asegurarnos de agregar el encabezado traceparent. El comando incorporado fetch No existe el middleware, por lo que lo empaquetaremos manualmente, como el shawarma de JavaScript. no es tan aterrador especialmente en comparación con cómo se hace en la práctica.

// pass the original function into our wrapping logic
function patchFetch(originalFetch) {
  // return a function with the same signature, but that executes our logic too
  return async function patchedFetch(resource, options = {}) {
    // generate and add the traceparent header
    let ctx = getContext();

    if (!options.headers) {
      options.headers = {};
    }
    options.headers("traceparent") = getTraceParent(ctx);

    // run the underlying fetch function, but wrap it in a span and
    // pull out some info while we're at it
    let resp;
    await startSpan("fetch", async (span) => {
      span.setAttributes({ "http.url": resource });
      resp = await originalFetch(resource, options);
      span.setAttributes({ "http.response.status_code": resp.status });
    });

    // pass along fetch's response. It's like we were never here
    return resp;
  };
}

Aquí Puede encontrar una aplicación de muestra y ver nuestro rastreo en acción.

Enviémoslo todo a Honeycomb.

Podemos enviar nuestros spans al terminal, pero esta no es la experiencia más conveniente. Antes de pasar a OpenTelemetry, es útil echar un vistazo a API de eventos de Honeycomb. Anteriormente, Honeycomb utilizaba un enfoque más sencillo de “simplemente envíenos JSON”. No recomiendan este método ahora, pero aún se puede aplicar a nuestro proyecto.

El código de exportador completo se puede encontrar Aquípero la parte más interesante es la lógica de formación de la carga útil.

// literally put all of the data together in one big json blob... like a log line!
// and then POST it to their API
function spanToHoneycombJSON(span) {
  return {
    ...Object.fromEntries(globalAttributes),
    ...Object.fromEntries(span.attributes),
    name: span.name,
    trace_id: span.traceID,
    span_id: span.spanID,
    parent_span_id: span.parentSpanID,
    start_time: span.startTime,
    duration_ms: span.durationMs,
  };
}

Como no utilizamos un formato estándar, tendremos que indicarle manualmente a Honeycomb qué campos serán responsables del ID de seguimiento, el ID de tramo y otros valores clave en la configuración del conjunto de datos. ¡Pero eso es todo lo que hay que hacer para construir una cascada de rastreo!

Entonces, ¿dónde está OpenTelemetry?

Entonces, tenemos registros de seguimiento sofisticados y la instrumentación y la propagación del contexto son bastante simples. Sin embargo, OpenTelemetry puede parecer un estándar grande y complejo, especialmente si ha visto especificación.

Es cierto, OpenTelemetry es un gran proyecto. Pero para nuestros propósitos sólo necesitamos una pequeña parte. Cuando instala el SDK de OpenTelemetry para el idioma de su elección, el SDK transfiere datos utilizando Protocolo abierto de telemetría (OTLP). Cada SDK de OpenTelemetry para cada idioma utiliza OTLP. Recopilador OpenTelemetry es un conjunto de herramientas para recibir, enviar y convertir datos en formato OTLP. Entonces OTLP es algo importante.

OTLP tiene el suyo propio especificación de protobufque permite comprimir los datos de telemetría en un mensaje binario compatible con cualquier plataforma, sistema operativo y arquitectura de CPU. En teoría, podríamos generar un módulo JavaScript para analizar y pasar mensajes protobuf desde .proto‑files, pero esto parece una tarea demasiado grande.

Afortunadamente, la especificación protobuf también proporciona mapeo al formato JSONy en el repositorio de especificaciones hay buen ejemplodonde puedes empezar. Así que no compliquemos las cosas.

La generación de carga útil es un poco más compleja que el antiguo formato de eventos Honeycomb. Están surgiendo nuevos términos como “recurso” y “contexto” y necesitamos trabajar un poco en los atributos. Pero si miras de cerca, son todos los mismos datos. La versión completa está en repositorios.

function spanToOTLP(span) {
  return {
    resourceSpans: (
      {
        resource: {
          attributes: toAttributes(Object.fromEntries(globalAttributes)),
        },
        scopeSpans: (
          {
            scope: {
              name: "minimal-tracer",
              version: "0.0.1",
              attributes: (),
            },
            spans: (
              {
                traceId: span.traceID,
                spanId: span.spanID,
                parentSpanId: span.parentSpanID,
                name: span.name,
                startTimeUnixNano: span.startTime * Math.pow(10, 6),
                endTimeUnixNano:
                  (span.startTime + span.elapsedMs) * Math.pow(10, 6),
                kind: 2,
                attributes: toAttributes(Object.fromEntries(span.attributes)),
              },
            ),
          },
        ),
      },
    ),
  };
}

Pero gracias al poder de los estándares, ya no estamos limitados a un solo proveedor de servicios. ¡Podemos enviar datos a cualquier servicio que admita OpenTelemetry!

Panal todavía funcional, por supuesto.

I Base ¡Mismo!

También podemos visualizar la telemetría localmente usando visor-de-escritorio-otel!

Incluso podemos renderizar datos en la terminal usando hotel-tui.

¿¡Esto es todo!?

Y eso es todo. Con 181 líneas de código, implementamos seguimiento, propagación de contexto, instrumentación y exportamos a un formato estándar que puede ser procesado por cualquier proveedor que lo admita. Y todo gracias a la magia de los estándares.

¿Es esto… incluso… legal?

Si ha seguido el proceso de cerca, hay una gran cantidad de especificaciones de OpenTelemetry que describen cómo debe comportarse y construirse el SDK de OpenTelemetry. Nuestra pequeña biblioteca no hace mucho de esto. Es sólo para entrenamiento, pero puedes imaginar un SDK simplificado que no sigue el estándar, como este por ejemplo. Trabajadores de Cloudflare. Da OTLP, pero no sigue todas las especificaciones. ¿Cómo deberíamos mirar esto?

Se le hizo una pregunta similar al cofundador de OpenTelemetry. A Ted Jan durante Día de la Comunidad OTel el pasado mes de junio. Anoté su respuesta lo mejor que pude. Parafraseando:

No nos importan las especificaciones. Nos preocupamos de que la caja negra participe en el rastreo y emita OTLP y convenciones semánticas. La verdadera especificación son los datos.

A mi modo de ver, si bien los SDK oficiales deben seguir la especificación, es razonable que otras implementaciones se desvíen del estándar siempre que un observador externo no vea ninguna diferencia en el comportamiento. Nuestra biblioteca no pasa la prueba debido a los atajos al analizar el encabezado traceparent, pero la solución no requerirá mucho código.

Casos… ¿por qué entonces opentelemetry-js es tan grande?

Si podemos crear un rastreador que funcione en menos de 200 líneas, ¿por qué hay tanto más código en el SDK oficial de JavaScript?

En este punto será útil mencionar algunas de las cosas que maneja el SDK de JavaScript y que nuestra biblioteca no maneja:

  • Almacenar en búfer y agrupar la telemetría saliente en un formato más eficiente. No debe enviar un lapso por solicitud HTTP: el proveedor de telemetría querrá hablar.

  • Trabaja tanto en el navegador como en el entorno de Node.

  • Manejo competente de errores, que le permite incluir la funcionalidad principal en una biblioteca.

  • Posibilidad de configuración. ¿Necesita hacer algo que vaya más allá de un simple ejemplo? Lo más probable es que sea posible utilizando el SDK.

  • Empaqueta automáticamente las bibliotecas utilizadas con frecuencia en instrumentación probada.

  • Optimización del rendimiento cuando se utiliza en circuitos cerrados.

  • Siguiente convenciones semánticas.

  • Soporte para dos tipos más de telemetría: registro y métricas.

¡Etcétera! Esperamos que este artículo le haya ayudado a empezar a comprender un poco mejor cuánto trabajo implica el fortalecimiento y la extensibilidad de bibliotecas como esta cuando se utilizan en proyectos del mundo real. Crear una biblioteca que pueda funcionar de manera confiable en todos los lugares donde implementamos JavaScript y que admita todas las funciones requeridas por una amplia gama de usuarios no es nada trivial.

¿Pero el rastreo? Sabemos que es simplemente registro elegante , ¿verdad?

Todo el código utilizado para este artículo se puede encontrar en Repositorios jmorrell/minimal‑nodejs‑otel‑tracer en GitHub.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *