Zurück zum Blog

Folgen und abonnieren

Nur auf Englisch verfügbar

Diese Seite ist momentan nur auf Englisch verfügbar. Wir entschuldigen uns für die Unannehmlichkeiten. Bitte besuchen Sie diese Seite später noch einmal.

OpenTelemetry – Teil 4: So instrumentieren Sie Fastly Fiddle

Andrew Betts

Principal Developer Advocate, Fastly

OpenTelemetry hat uns wirklich überzeugt. Wir haben bereits über die Gründe und Möglichkeiten für die Übermittlung von Telemetriedaten aus den VCL-Services und der neuen Compute Plattform von Fastly geschrieben. Der eigentliche Mehrwert von OpenTelemetry zeigt sich allerdings erst, wenn Sie die entsprechenden Tools, APIs und SDKs in Ihren gesamten Stack integrieren. Aber wie funktioniert das und ist es den Aufwand wert? Wir haben Fastly Fiddle von oben bis unten instrumentiert, um diese Frage zu beantworten.

Es ist gut zu wissen, dass Sie OpenTelemetry Daten auf der Edge generieren können, aber wenn Sie noch keinen einheitlichen Überblick über Ihren gesamten Stack haben, fragen Sie sich vielleicht, ob das den Aufwand überhaupt wert ist. Diese Frage lässt sich nur beantworten, wenn wir uns die Auswirkungen der Instrumentierung eines kompletten Systems vor Augen führen. Aus diesem Grund möchte ich Ihnen in diesem Blogpost erläutern, wie wir Fastly Fiddle instrumentiert haben, wie die daraus resultierenden Traces aussehen und welche Erkenntnisse wir daraus ziehen können.

Fiddle besteht aus einer ReactJS Anwendung im Browser, einem NodeJS Backend und einem Fastly VCL-Service. Außerdem greift es auf mehrere andere interne Systeme und Fastly Services zu. Seine Architektur ist komplex und nicht leicht zu durchschauen.

Wenn Sie im Browser bei einem Fiddle auf „RUN“ klicken, lösen Sie nicht nur einen einzelnen HTTP-Request aus. Die React App führt einen ersten API-Call durch, um eine Ausführungssitzung zu starten, und erhält eine ID zurück. Diese ID wird anschließend verwendet, um eine Verbindung zu einem Stream herzustellen, über den wir die Instrumentierungsdaten vom Server empfangen können, sobald sie verfügbar sind. Irgendwann sind keine Daten mehr verfügbar, oder die Sitzung läuft ab und die Ausführung wird beendet. Das ist der gesamte Lebenszyklus einer Fiddle Ausführung.

Tracing funktioniert anhand von Zeitspannen (Spans), die die Form eines Baumes haben (ein Baum, weil eine Zeitspanne mehrere „Children“, aber nur einen „Parent“ haben kann). Als „Root Span“ bezeichnet man den Zeitraum, den eine Aufgabe oder Transaktion insgesamt in Anspruch nimmt. Dieser lässt sich dann mithilfe von „Child Spans“ in granularere Aktivitäten herunterbrechen.

Ich habe mich ans Werk gemacht und versucht, ein Tracing für eine Ausführung in Fiddle zu erhalten, das alle Komponenten der Plattform abdeckt, die in diesen Prozess involviert sind. Aber genug der langen Worte! Kommen wir lieber direkt zum Ergebnis:

Hier sehen wir also eine übersichtliche interaktive Visualisierung, die eine Fiddle Ausführung dokumentiert und dabei mehrere Front-End-Requests, Edge Proxying, Serververarbeitung, Datenbankabfragen und Instrumentierungsaufrufe an interne Microservices umfasst.

Lassen Sie uns Schritt für Schritt aufschlüsseln, wie wir zu diesem Ergebnis gekommen sind.

Initialisieren der Telemetrie (über React)

Zunächst erstellen wir eine Tracing-Konfiguration für das React.JS Frontend in einer neuen Datei namens „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() });

Dieses Setup hat Ähnlichkeiten mit dem, das wir in Compute für unseren letzten Post verwendet haben. OpenTelemetry bietet zahlreiche separate Bibliotheken, die wir in Kombination verwenden müssen, aber letztendlich beschränkt sich der Großteil der Komplexität auf diese Datei. Hier sehen Sie, wie sich die einzelnen Komponenten zusammenfügen:

  • Resource: Definiert die Anwendung

  • Exporter: Sendet serialisierte Span-Daten an einen Collector Endpoint

  • Span Processor: Serialisiert Span-Daten und leitet sie an einen Exporter weiter

  • Context Manager: Überwacht den aktuellen aktiven Span, sodass die Komponenten der Anwendung, die instrumentiert werden, nicht übereinander Bescheid wissen müssen (Um das Kontext-Tracking durch asynchrone Operationen im Browser durchzuführen, verwendet OTel zone-js und stellt es als ZoneContextManager zur Verfügung)

  • Provider: Verknüpft sich mit der Laufzeitumgebung, um dort eine Implementierung der OpenTelemetry API zu registrieren

Wenn der Code in der Anwendung zum Erstellen von Spans einen Call an die OTel API durchführt, verwendet der Provider Kontext aus dem Context Manager, erstellt den Span und übergibt ihn zur Serialisierung und zum Export an den Span Processor.

Das klingt ziemlich kompliziert, ist aber sinnvoll. Schließlich ist es wichtig, dass die OpenTelemetry API von überall in der Anwendung aufgerufen werden kann und zwei grundlegende Anforderungen erfüllt:

  1. Die Calls sollen auch dann nicht fehlschlagen, wenn es keine Tracing-Konfiguration in der Anwendung gibt.

  2. Mit diesen Calls erzeugte Spans werden ineinander verschachtelt, ohne dass Sie etwas über ihre Parent- oder Child-Spans wissen müssen.

Dies ist deshalb so wichtig, weil wir vielleicht nicht die einzigen sind, die OpenTelemetry Calls in unserer Anwendung nutzen. Es gibt drei Möglichkeiten, Spans zu erzeugen:

  1. Über Ihren eigenen Code: Erzeugen Sie Spans explizit über OTelAPI.tracer.startSpan(...).

  2. Mithilfe von Abhängigkeiten: Der Autor einer Abhängigkeit, die Sie in Ihrer Anwendung verwenden, hat einige OTelAPI.tracer.startSpan(...)-Calls in seinen Code integriert (und die OpenTelemetry API als Abhängigkeit für sein Modul aufgeführt).

  3. Durch Instrumentierungsmodule: OpenTelemetry Instrumentierungsmodule können beim Provider registriert werden, in der Regel um Telemetrie nachträglich zu Standardfunktionen der Plattform wie HTTP-Calls hinzuzufügen.

Wenn wir in unserem Code explizite Spans für Aufgaben mit langer Laufzeit hinzufügen, könnten wir den Kontext (also den übergeordneten Span) manuell weitergeben. Das hilft uns aber nicht bei Vorgängen, die in Abhängigkeit voneinander erfolgen (2) oder die OpenTelemetry über ein Instrumentierungsmodul überwacht (3). Diese Arten von autonom gestalteten Spans holen sich den aktiven Kontext automatisch aus dem Context Manager. Außerdem können Abhängigkeiten mit integrierter Telemetrie gar nicht wissen, ob OpenTelemetry in unserer Anwendung überhaupt zum Einsatz kommt. Die OpenTelemetry API selbst ist also absichtlich nur eine Typendefinition und keine Implementierung.  

Es ist ganz, als ob Sie durch das Hinzufügen von Spans zu Ihrem Code eine Art Revisionsklappe schaffen, die bei Bedarf geöffnet werden kann, um einen Blick ins Innere zu werfen. Die OpenTelemetry API wird aber nur dann aktiv, wenn ein Provider registriert ist.

Wir wissen erst dann wirklich, ob eine unserer Abhängigkeiten Telemetriedaten sendet, wenn wir sie sammeln und den Trace rendern. Die Instrumentierungsmodule werden allerdings manuell in der Konfigurationsdatei für das Tracing hinzugefügt. Für die Browser-App von Fiddle wollen wir in erster Linie die Fetch-API instrumentieren, also fügen wir dies der Datei tracing.ts hinzu:

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 });
        }
      },
    }),
  ],
});

Jetzt ist die Telemetriekonfiguration abgeschlossen und jeder Anwendungscode, der einen Call auf die OpenTelemetry API durchführt, um Spans zu erstellen, sowie jeder Call auf Fetch veranlasst den Provider, aktiv zu werden.

Hinzufügen von Spans zum React Code

Abschließend müssen wir jetzt noch unsere eigenen nutzerdefinierten Spans hinzufügen. Das Prinzip der Trennung zwischen der Konfiguration des OpenTelemetry Anbieters und der API gilt auch für unsere eigene Verwendung der API. Wir müssen in unserem Anwendungscode also eigentlich nichts aus tracing.ts importieren. Stattdessen rufen wir die OpenTelemetry API separat auf. Hier sehen Sie, wie das in einer funktionierenden React Komponente aussehen könnte:

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

};

Dies kann sich aber ziemlich schnell als mühselig erweisen. So empfiehlt die Dokumentation von OpenTelemetry zum Beispiel, Fehler herauszufiltern und sie dem Span zuzuweisen. Dies würde uns für jeden Span im Code Folgendes liefern:

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

};

Es gibt darüber hinaus noch weitere Unannehmlichkeiten. Wir müssen getTracer() aufrufen, um das Objekt zu erhalten, mit dem wir startActiveSpan aufrufen können – und OpenTelemetry empfiehlt, dies nur zu tun, wenn es unbedingt nötig ist:

Es wird im Allgemeinen empfohlen, „getTracer“ nur bei Bedarf in Ihrer Anwendung aufzurufen, anstatt die Tracer-Instanz zum Rest Ihrer Anwendung zu exportieren. So lassen sich komplexere Probleme beim Laden der Anwendung vermeiden, wenn andere erforderliche Abhängigkeiten mit im Spiel sind.

Werfen wir aber auch noch einen Blick auf das zweite Argument für startActiveSpan: { attributes: { ... } }. Hierbei handelt es sich um eine SpanOptions-Datei, mit der Sie beispielsweise die „Art“ des Spans anpassen oder eine nutzerdefinierte Startzeit angeben können. Meistens wollen wir aber nur nutzerdefinierte Attribute bereitstellen, sodass es lästig ist, diese innerhalb eines Attributschlüssels verschachteln zu müssen …

Es regt sich langsam der Verdacht, dass wir das Ganze ein wenig abstrahieren sollten, aber zunächst müssen wir uns um ein paar komplexere Grenzfälle kümmern, in denen der Kontext nicht automatisch vom Context Manager erkannt werden kann.

Manuelle Kontextweitergabe

Wenn wir in Fiddle eine Ausführung triggern, möchten wir, dass der Root Span eine Reihe von asynchronen Aktivitäten abdeckt, die ziemlich weit unten im Komponentenbaum stattfinden. Die nachfolgende POST-Anfrage erfolgt in der Root-Komponente <App>, während die Requests zum Streamen der Ergebnisse für dieselbe Ausführung in einer <Result>-Komponente erfolgen. Dennoch müssen sie wie folgt verschachtelt werden:

Ich habe noch keine bessere Möglichkeit dafür gefunden, als den neuen Kontext einzulesen und ihn dann an eine Komponente zu übergeben. In der High-Level-Komponente  legen wir zum Beispiel den Span und den neuen aktiven Kontext, der mit ihm verbunden ist, in React als State Hook fest:

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

Dieser wird dann über Props an die untergeordneten Child-Komponenten weitergegeben und kann auch nach einem Wechsel des aktiven Kontexts genutzt werden, um einen untergeordneten Child-Span hinzuzufügen:

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

… oder um den eigentlichen Span zu beenden:

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

Hier kommt weiterer Boilerplate-Code ins Spiel: In solchen Szenarien wird der neue Span als Parameter an den Callback übergeben. Wir müssen aber context.active() von der OTel API aufrufen, um den neuen Kontext zu erhalten. Dies sieht auf den ersten Blick aus, als hätten wir uns damit ein Eigentor geschossen!

Abstraktion für Trace-Calls

Als Alternative habe ich beschlossen, eine Utility-Funktion zu erstellen und sie in tracing.ts einzufügen, sie aber zu exportieren, damit wir sie überall dort verwenden können, wo wir ein Tracing durchführen möchten.

Ich wollte eine Funktion, die im Idealfall immer einen Tracer-Namen und einen Span-Namen und optional eine Reihe von Attributen sowie einen Parent-Kontext und schließlich auch eine Callback-Funktion akzeptiert. Wenn der bereitgestellte Callback zwei Argumente erwartet, rufe ich ihn mit dem neuen Span und Kontext auf und erwarte, dass er den Span von selbst beendet. Erwartet der Callback keine Argumente, dann beende ich den Span, wenn das von der Funktion zurückgegebene Versprechen aufgelöst wird.

Hier ist also meine Idee für eine neue telemetry-utils.ts-Datei:

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;
    }
  });
}

Jetzt kann ich dieses Modul in „tracing.ts“ importieren und erneut aus dieser Datei exportieren:

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

Damit kann ich jetzt einen Call für einfache Szenarien wie dieses bei traceSpan durchführen:

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

};

Ich muss den Span nicht mehr explizit beenden, da ich keine Parameter im Callback erhalte. Attribute müssen nicht verschachtelt werden und wir müssen keinen Tracer vorinitialisieren. Wir können einfach den Tracer-Namen als erstes Argument übermitteln, und ich erhalte automatisch eine Fehlerbehandlung.

Das Ganze funktioniert auch bei komplexeren Szenarien:

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

};

Da mein Callback zwei Argumente akzeptiert, endet der Span hier nicht automatisch, sondern ich muss ihn selbst beenden. Ich könnte ihn sogar in einen State Hook von React oder eine Component Prop stecken und ihn später von einem anderen Ort in der App beenden. Da ich Zugriff auf den Kontext habe, kann ich auch explizit neue Spans starten, die diesen als Parent haben.

Auf jeden Fall können wir Fehler jetzt im Trace behandeln, wir verwenden eine neue Tracer-Instanz (wie in der OpenTelemetry Dokumentation empfohlen) und der Boilerplate-Code ist minimal.

Sind Front-End-Traces zuverlässig?

Kurz gesagt, nein. Alle meine Traces gehen an eine Instanz eines OpenTelemetry Collectors, der so konfiguriert ist, dass er OTLP JSON über HTTP unterstützt. Ich stelle diesen Server hinter einem Fastly Service zur Verfügung, damit ich die Traces an denselben Origin-Server senden kann, von dem aus ich auch meine Website bediene. Natürlich kann jeder Trace-Daten an diesen Endpoint senden. Wenn das ein Problem ist, können Sie es lösen, indem Sie alle Traces verwerfen, die nicht auch Spans vom Backend enthalten.

Bisher habe ich nur herausgefunden, wie das rückwirkend erledigt werden kann. Mein Gefühl sagt mir aber, dass dies auch im Collector oder sogar im Fastly Service, der dem Collector vorgeschaltet ist, möglich sein sollte.

Tracing des NodeJS-Backends

Dieselbe traceSpan-Funktion kann genauso in NodeJS verwendet werden. Allerdings benötigen wir etwas andere Bibliotheken, um das Tracing zu konfigurieren. Deshalb habe ich die Dateien tracing.ts und tracing-utils.ts getrennt.

Jetzt kann ich eine neue tracing.ts-Datei für meine serverseitige Tracing-Konfiguration erstellen:

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";

Die Unterschiede sind relativ deutlich:

  • Anstelle von FetchInstrumentation verwenden wir getNodeAutoInstrumentations von @opentelemetry/auto-instrumentations-node, einer raffinierten Bibliothek, die NodeJS-Server-Frameworks wie Fastify und Express.JS (das Fiddle verwendet) instrumentiert.

  • Statt WebTracerProvider verwenden wir NodeTracerProvider.

Das Tolle daran ist, dass meine serverseitige telemetry.ts genau dieselbe nutzerdefinierte traceSpan-Funktion importieren und wieder exportieren kann, die meine clientseitige React App verwendet.

Nun kann ich ähnliche traceSpan-Calls in meinem Express.JS Backend hinzufügen:

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
  });
});

Genau wie beim Frontend gibt es auch hier Fälle, in denen ich nicht möchte, dass der Span endet, wenn mein asynchrones Funktionsversprechen aufgelöst wird. In diesem Fall kann ich mich dafür entscheiden, zwei Argumente im Callback zu erhalten und dann einen expliziten span.end()-Call durchzuführen.

Edge-Sandwich

Die React App im Frontend und die NodeJS App im Backend sind Teil desselben verteilten Systems, und Fastly sitzt genau dazwischen. Wie ich bereits im ersten Teil dieser Blogpost-Reihe erwähnt habe, ist eine der besten Eigenschaften von OpenTelemetry die Fähigkeit, die von Fastly gehosteten Komponenten Ihres Systems als Teil derselben Gesamtarchitektur zu erkennen.

Wie Sie Fastly in OpenTelemetry einbinden, haben wir ja bereits in Teil 2 und 3 dieser Serie ausführlich behandelt. Hier erfahren Sie mehr:

Ich hoffe, dass Ihnen dieser Blogpost einen Eindruck von den Vorteilen vermittelt, die OpenTelemetry in Verbindung mit anderen Komponenten Ihres Systems bietet, die nicht zu Fastly gehören.

Nächste Schritte

Meine Ziele für diesen Blogpost habe ich zwar erreicht, aber ich habe noch nicht jeden Teil von Fiddle instrumentiert: Compute Fiddles verwenden Compiler- und Runtime-Microservices, und sowohl Compute als auch VCL-Fiddles verwenden einen Instrumentierungs-Proxy, um Backend Fetches zu inspizieren. Das alles sollte ebenfalls instrumentiert werden, um ein noch umfassenderes Bild von OpenTelemetry zu erhalten.

Mich würde interessieren, ob Sie OpenTelemetry Daten von Ihrem Fastly Service erhalten und was Sie damit machen. Schreiben Sie uns auf Twitter und teilen Sie Ihre Erfahrung.


Alle vier Teile unserer OpenTelemetry Blogserie sind nun online: