Volver al blog

Síguenos y suscríbete

Sólo disponible en inglés

Por el momento, esta página solo está disponible en inglés. Lamentamos las molestias. Vuelva a visitar esta página más tarde.

OpenTelemetry (IV): instrumentación de Fastly Fiddle

Andrew Betts

Principal Developer Advocate, Fastly

OpenTelemetry nos tiene entusiasmados. Ya escribimos algunos artículos explicando los motivos y describiendo los modos de emitir telemetría desde los servicios VCL de Fastly y nuestra nueva plataforma Compute. Sin embargo, sus efectos positivos no se perciben del todo hasta que no lo añades a tu stack. ¿Qué aspecto tendrá? ¿Valdrá la pena? Pues bien, hemos instrumentado por completo Fastly Fiddle para averiguarlo.

Es cierto que se pueden generar datos de OpenTelemetry en el edge, pero si no tienes un punto único de observabilidad de todo tu stack, quizás te preguntes si te merece la pena usarlo. Para responder a esa pregunta, lo único que podemos hacer es mostrarte el efecto de la instrumentación de todo un sistema. Por eso, en este artículo voy a explicar cómo instrumentamos Fiddle, y mostraré los seguimientos y la información que puede revelar.

Fiddle se compone de una aplicación ReactJS en el navegador, un backend NodeJS y un servicio VCL de Fastly. Además, se comunica con otros sistemas internos y servicios de Fastly. Presenta, pues, una arquitectura compleja y difícil de entender.

Al hacer clic en el botón RUN de Fiddle en el navegador, se desencadena mucho más que una petición HTTP. La aplicación React realiza una primera llamada a la API para iniciar una sesión de ejecución y devuelve un ID, que utiliza para conectarse a una secuencia donde podemos recibir los datos de instrumentación del servidor en cuanto están disponibles. La ejecución finaliza cuando ya no hay más datos o se agota el tiempo de la sesión. Ese sería el ciclo de vida completo de una ejecución de Fiddle.

El seguimiento se basa en «spans» (intervalos de tiempo), representados en forma de árbol, ya que un solo elemento principal o troncal puede tener varios elementos secundarios. El intervalo «root span» describe la duración completa de una tarea o transacción, que en los intervalos secundarios aparecen fragmentadas en actividades más detalladas.

Voy a añadir seguimientos a Fiddle para poder realizar el seguimiento de una ejecución que abarca todos los componentes de la plataforma que intervienen en dicho proceso. En fin, vayamos al grano y veamos el resultado:

¡Listo! En una sola visualización interactiva, tenemos documentada una ejecución de Fiddle en su totalidad: peticiones de frontend, proxis del edge, procesamiento de servidores, consultas a bases de datos y llamadas de instrumentación a microservicios internos.

Pero ¿cómo lo hemos hecho? Vamos a verlo por partes.

Inicialización de la telemetría (empezar por React)

Para empezar, vamos a crear una configuración de seguimiento para el frontend React.JS en un nuevo archivo denominado tracing.ts:

import { Span, SpanStatusCode, Context } from "@opentelemetry/api";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { Resource } from "@opentelemetry/resources";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { FetchError } from "@opentelemetry/instrumentation-fetch/build/src/types";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";

const resource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]: "Fiddle Frontend",
});
const provider = new WebTracerProvider({ resource });
const exporter = new OTLPTraceExporter({
  url: process.env.REACT_APP_OTEL_HTTP_COLLECTOR_BASE_URL + "/v1/traces",
});

provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register({ contextManager: new ZoneContextManager() });

Es muy similar a la configuración que usamos en Compute en el artículo anterior</u>. OpenTelemetry ofrece un montón de bibliotecas distintas que tenemos que usar conjuntamente, aunque este archivo es el más complejo de todos. Así es como encajan las piezas:

  • Resource: define la aplicación.

  • Exporter: envía datos de intervalos serializados a un punto de conexión de recopilador</u>.

  • Span processor: serializa los datos de intervalos y los pasa a un exportador.

  • Context manager: realiza un seguimiento del intervalo activo actual para que las partes de la aplicación que se instrumentan no tengan por qué tener conocimiento del resto (para manejar el contexto de seguimiento mediante operaciones asíncronas en el navegador, OpenTelemetry usa zone-js</u> y lo expone como ZoneContextManager).

  • Provider: se enlaza al tiempo de ejecución para registrar una implementación de la API de OpenTelemetry.

Cuando el código de la aplicación llama a la API de OpenTelemetry para crear intervalos, el proveedor usa el contexto del gestor de contexto, crea el intervalo y lo pasa al procesador de intervalos para serializarlo y exportarlo.

Aunque parezca muy complicado, tiene sentido porque es importante que la API de OpenTelemetry pueda llamarse desde cualquier lugar de la aplicación y que cumpla dos requisitos fundamentales:

  1. que las llamadas no den error aunque la aplicación no incluya configuración de seguimiento;

  2. que los intervalos creados a partir de esas llamadas se aniden entre sí sin que deban tener conocimiento alguno de su intervalo principal o sus intervalos secundarios.

Esto es importante porque puede ser que otras personas también hagan llamadas a OpenTelemetry en mi aplicación. Hay tres maneras posibles de generar intervalos:

  1. En tu código: de forma explícita en tu propio código mediante OTelAPI.tracer.startSpan(...).

  2. Mediante dependencias: el autor de una dependencia que estés usando en tu aplicación puede haber incluido algunas llamadas OTelAPI.tracer.startSpan(...) en su código (y habrá incluido la API de OpenTelemetry como una dependencia de su módulo).

  3. Mediante módulos de instrumentación: los módulos de instrumentación de OpenTelemetry se pueden registrar con el proveedor, normalmente para readaptar la telemetría a las funciones estándar de la plataforma, como las llamadas HTTP.

Si añadimos intervalos explícitos a tareas de larga duración en nuestro código, podríamos pasar contexto (por ejemplo, el intervalo principal) de forma manual, pero eso no serviría de nada con las operaciones que se producen en las dependencias (2) o que OpenTelemetry supervisa mediante un módulo de instrumentación (3). Estos tipos de intervalos creados de forma autónoma captarán el contexto activo del gestor de contexto automáticamente. Además, las dependencias que se envían mediante la telemetría integrada ni siquiera saben si estamos usando OpenTelemetry en nuestra aplicación, de modo que la API de OpenTelemetry no es más que la definición de un tipo sin implementación alguna.  

Al añadir intervalos a tu código, es como si estuvieras creando una especie de «mirilla de inspección» que permitiera echar un vistazo. Sin embargo, la API de OpenTelemetry solo actúa si el proveedor está registrado.

No sabremos si alguna de nuestras dependencias está emitiendo telemetría hasta que la recopilemos y representemos el seguimiento para averiguarlo. No obstante, los módulos de instrumentación se añaden manualmente al archivo de configuración de seguimiento. En la aplicación para el navegador de Fiddle, básicamente queremos instrumentar la API de captura</u>, así que debemos añadir lo siguiente al archivo tracing.ts:

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: /.*/g,
      clearTimingResources: true,
      applyCustomAttributesOnSpan: (
        span: Span,
        request: Request | RequestInit,
        result: Response | FetchError
      ) => {
        const attributes = (span as any).attributes;
        if (attributes.component === "fetch") {
          span.updateName(
            `${attributes["http.method"]} ${attributes["http.url"]}`
          );
        }
        if (result.status && result.status > 299) {
          span.setStatus({ code: SpanStatusCode.ERROR });
        }
      },
    }),
  ],
});

Ahora ya hemos finalizado la configuración de la telemetría, y cualquier código de la aplicación que llame a la API de OpenTelemetry para crear intervalos y cualquier llamada de captura desencadenará la acción del proveedor.

Agregación de intervalos al código de React

Por último, debemos añadir nuestros propios intervalos personalizados. La idea de separar la configuración del proveedor de OpenTelemetry de la API también es aplicable a nuestro propio uso de la API, así que no es necesario que importemos nada de tracing.ts en nuestro código de la aplicación; más bien deberíamos llamar a la API de OpenTelemetry de forma independiente. Este sería el fragmento de código de un componente de la función React:

import OTel from "@opentelemetry/api";

const oTelTracer = OTel.trace.getTracer("Foo");

const Foo: FunctionComponent<Props> = () => {

  const openStream = (): void => {
    await oTelTracer.startActiveSpan("Stream results", async (span) => {
      // ... do slow stuff, HTTP calls etc
      span.end();
    });
  };

  // ... rest of component code

};

Sin embargo, esto puede complicarse rápidamente. Por ejemplo, en la documentación de OpenTelemetry se recomienda capturar los errores y asignarlos al intervalo</u>, lo que daría como resultado el siguiente código para cada intervalo:

import OTel from "@opentelemetry/api";

const oTelTracer = OTel.trace.getTracer("Foo");

const Foo: FunctionComponent<Props> = () => {

  const openStream = (): void => {
    await oTelTracer.startActiveSpan("Stream results", { attributes: { url }}, async (span) => {
      try {
        // ... do slow stuff, HTTP calls etc
      } catch (ex) {
        span.recordException(ex);
        span.setStatus({ code: opentelemetry.SpanStatusCode.ERROR });
      }
      span.end();
    });
  };

  // ... rest of component code

};

Pero eso no es todo: también debemos llamar a getTracer() para obtener el objeto que nos permita llamar a startActiveSpan, y OpenTelemetry recomienda</u> hacerlo únicamente en caso necesario:

Normalmente, se recomienda llamar a getTracer en la aplicación solo en caso necesario, en lugar de exportar la instancia de seguimiento al resto de la aplicación. Esto ayuda a evitar problemas de carga de la aplicación más complejos cuando intervienen otras dependencias necesarias.

Como podemos ver, el segundo argumento de startActiveSpan: { attributes: { ... } } es SpanOptions</u>, que nos permite personalizar elementos como la «clase» de intervalo o especificar un tiempo de inicio personalizado. Sin embargo, la mayoría de las veces simplemente querremos especificar atributos personalizados, por lo que resulta engorroso tener que anidarlos dentro de una clave attributes.

Al parecer, tendremos que añadir un poco de abstracción, pero antes debemos tener en cuenta algunos casos más complejos en el edge en los que el gestor de contexto no puede detectar automáticamente el contexto.

Propagación manual del contexto

Cuando activamos una ejecución en Fiddle, nos interesa que el intervalo raíz abarque un conjunto de actividades asíncronas que ocurren en niveles bastante por debajo en el árbol de componentes. En el ejemplo siguiente, la petición POST se produce en el componente <App> de raíz, mientras que las peticiones para transmitir los resultados de la misma ejecución ocurren en un componente <Result>. En todo caso, necesitamos que se aniden del siguiente modo:

No he encontrado mejor forma de hacerlo que leer el nuevo contexto y luego pasarlo a un componente. Por ejemplo, en el componente <App> de nivel superior, establecemos el intervalo y el nuevo contexto activo asociado a él en un enlace de estado React:

oTelTracer.startActiveSpan("Execute fiddle", async (span) => {
  // ... do stuff here
  setExecutionSession({
    id: sessionID, 
    telemetry: { span, context: OTel.context.active() }
  });
  // <-- Intentionally not ending the span here
});

A continuación, ese estado pasa a los componentes secundarios a través de propiedades y puede usarse incluso después de que el contexto activo haya cambiado, para adjuntarle un intervalo secundario:

oTelTracer.startActiveSpan("Stream results", {}, props.session.telemetry.context, async (childSpan) => {
  // ... do work
  childSpan.end();
});

O bien, para finalizar el propio intervalo:

props.session.telemetry.span.end();

Esto implica algo más de código reutilizable: en este tipo de escenarios, el nuevo intervalo aparece como un parámetro de la devolución de llamada, pero tenemos que llamar a context.active() desde la API de OTel para obtener el nuevo contexto. ¡Madre mía, qué complicado!

Una abstracción para las llamadas de seguimiento

Visto el panorama, decidí crear una función de utilidad y añadirla a tracing.ts y, además, exportarla para poder usarla cada vez que queramos realizar seguimientos.

Me interesaba crear una función que siempre tuviera un nombre de seguimiento y un nombre de intervalo; que, opcionalmente, tuviera un conjunto de atributos y un contexto principal; y, por último, una función de devolución de llamada. Si dicha función espera dos argumentos, la llamaré mediante el nuevo intervalo y el contexto, y esperaré que ponga fin al intervalo. Si la función no espera ningún argumento, finalizaré el intervalo cuando se cumpla la promesa que devuelve la función.

Esto es lo que obtuve con un nuevo archivo telemetry-utils.ts:

import OTel, { Attributes, Span, Context, SpanStatusCode } from '@opentelemetry/api';

type TracedCallback<T> = (span?: Span, context?: Context) => T;

export async function traceSpan <T>(tName: string, sName: string, arg3: Attributes | Context | TracedCallback<T>, arg4?: Context | TracedCallback<T>, arg5?: TracedCallback<T>): Promise<T> {
  const attrs = arguments.length >= 4 ? arg3 as Attributes : {};
  const parentContext = arguments.length == 5 ? arg4 as Context : OTel.context.active();
  const callback = (arguments.length == 5 ? arg5 : arguments.length == 4 ? arg4 : arg3) as TracedCallback<T>;
  const tracer = OTel.trace.getTracer(tName);
  const handlesSpanEnd = callback.length;
  return tracer.startActiveSpan(sName, { attributes: attrs }, parentContext, async (span) => {
    const boundCallback = (handlesSpanEnd) ? callback.bind(null, span, OTel.context.active()) : callback;
    try {
      const result = await boundCallback();
      if (!handlesSpanEnd) span.end();
      return result;
    } catch (e) {
      span.recordException(e);
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.end();
      throw e;
    }
  });
}

Ahora ya puedo importar y volver a exportar ese módulo en mi archivo tracing.ts:

export * from "../telemetry-utils";

Y, seguidamente, puedo llamar a traceSpan en casos sencillos como este:

import { traceSpan } from "./lib/telemetry";

const Foo: FunctionComponent<Props> = () => {

  const openStream = (): void => {
    await traceSpan("Foo", "Stream results", { url }, async () => {
      // ... do slow stuff, HTTP calls etc
    });
  };

  // ... rest of component code

};

Ya no tengo que finalizar el intervalo de forma explícita porque no recibo ningún parámetro en la función de devolución de llamada. Tampoco es necesario anidar los atributos ni preinicializar un seguimiento, ya que basta con pasar el nombre del mismo como el primer argumento. Y, por si fuera poco, los errores se controlan automáticamente.

También funciona en casos más complejos:

import { traceSpan } from "./lib/telemetry";

const Foo: FunctionComponent<Props> = () => {

  const openStream = (): void => {
    await traceSpan("Foo", "Execute fiddle", {}, async (span, context) => {
      setExecutionSession({
        id: sessionID, 
        telemetry: { span, context }
      });
    });
  };

  // ... rest of component code

};

En este ejemplo, dado que mi función de devolución de llamada acepta dos argumentos, el intervalo no finaliza automáticamente y yo decido si quiero terminarlo o no. Esto podría hacerlo añadiéndolo a un enlace de estado React o a una propiedad de componente y finalizándolo más adelante en alguna otra parte de la aplicación. Además, como tengo acceso al contexto, puedo iniciar de forma explícita nuevos intervalos que tengan este como elemento principal.

Ahora, en todos los casos, obtenemos control de errores en el seguimiento, utilizamos una instancia nueva de tracer (tal y como se aconseja en la documentación de OpenTelemetry</u>) y usamos el mínimo código reutilizable.

¿Podemos fiarnos de los seguimientos del frontend?

La respuesta es sencilla: no. Todos mis seguimientos van a parar a una instancia de un recopilador OpenTelemetry</u> configurado para poder usarse con OTLP JSON mediante HTTP</u>, y expongo ese servidor por detrás de un servicio de Fastly para poder enviar los seguimientos al mismo origen desde el que se distribuye mi sitio</u>. Por supuesto, cualquiera puede enviar datos de seguimiento a este punto de conexión, de modo que, si eso supone un problema, se puede solucionar descartando los seguimientos que no incluyan intervalos del backend.

De momento, solo he sido capaz de hacerlo retroactivamente, pero creo que podría hacerse en el recopilador, o incluso en el servicio de Fastly en el frontend del recopilador.

Seguimiento del backend NodeJS

Esa misma función traceSpan puede usarse de manera idéntica en NodeJS. Sin embargo, como necesitamos unas bibliotecas algo distintas para configurar el seguimiento, he separado los archivos tracing.ts y tracing-utils.ts.

Ahora ya puedo crear un nuevo archivo tracing.ts para mi configuración de seguimiento del lado del servidor:

import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

const oTelResource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]: 'Fiddle App'
});

const traceExporter = new OTLPTraceExporter({ url: process.env.OTEL_HTTP_COLLECTOR_BASE_URL + "/v1/traces" });
const spanProcessor = new SimpleSpanProcessor(traceExporter);
const traceProvider = new NodeTracerProvider({ resource: oTelResource });

traceProvider.addSpanProcessor(spanProcessor);
traceProvider.register();

registerInstrumentations({ instrumentations: getNodeAutoInstrumentations() });

export * from "../telemetry-utils";

Las diferencias son bastante evidentes:

  • En lugar de FetchInstrumentation, usamos getNodeAutoInstrumentations de @opentelemetry/auto-instrumentations-node</u>, una maravillosa biblioteca que instrumenta marcos de servidor NodeJS como Fastify y Express.JS (que es el que usa Fiddle).

  • En lugar de WebTracerProvider, usamos NodeTracerProvider.

Una de las cosas más alucinantes es que mi archivo telemetry.ts del lado del servidor puede importar y volver a exportar exactamente la misma función traceSpan personalizada que utiliza mi aplicación React del lado del cliente.

Ahora puedo añadir el mismo estilo de llamadas traceSpan a mi backend Express.JS:

import { traceSpan} from './lib/telemetry';

app.put('/fiddle/:id, parseJson, async (req, res, next) => {
  const fiddle = await Fiddle.get(req.params.id);
  fiddle.updateFrom(req.body);

  await traceSpan("Server", "Publish on save", () => fiddle.publish());
  await fiddle.save();

  res.json({
    fiddle,
    valid: fiddle.isValid(),
    lintStatus: fiddle.lintStatus
  });
});

Al igual que con el frontend, si no quiero que el intervalo finalice cuando se cumpla la promesa de mi función asíncrona, puedo optar por recibir dos argumentos en la función de devolución de llamada y luego llamar a span.end() de forma explícita.

Un edge entre dos tierras

La aplicación React en el frontend y la aplicación NodeJS en el backend forman parte del mismo sistema descentralizado, y Fastly se sitúa entre ambas. Tal y como expliqué en la primera parte de esta serie</u>, una de las mayores virtudes de OpenTelemetry es su capacidad de reconocer que los componentes de tu sistema alojados en Fastly forman parte de una misma arquitectura global.

En la segunda y la tercera parte de esta serie hablamos en profundidad de cómo añadir Fastly a tu sistema OpenTelemetry. En esos artículos encontrarás información sobre lo siguiente:

Espero que en este artículo haya podido darte una idea de las ventajas que tiene obtener visibilidad de OpenTelemetry en tus servicios de Fastly cuando se combina con los demás componentes de tu sistema que no son de Fastly.

Siguientes pasos

En este artículo he podido demostrar lo que pretendía conseguir, pero lo cierto es que no he instrumentado Fiddle del todo: los fiddles de Compute usan microservicios de compilador y tiempo de ejecución, y los fiddles tanto de Compute como de VCL usan un proxy de instrumentación para inspeccionar las capturas del backend. Todo esto se presta a la instrumentación, para obtener una panorámica más completa de OpenTelemetry.

Me encantaría saber si obtienes datos de OpenTelemetry de tu servicio de Fastly y para qué los usas. ¿Por qué no nos escribes en Twitter</u> y nos lo cuentas?


Ya están publicadas las cuatro partes de nuestra serie de artículos sobre OpenTelemetry: