ブログに戻る

フォロー&ご登録

英語のみで利用可能

このページは現在英語でのみ閲覧可能です。ご不便をおかけして申し訳ございませんが、しばらくしてからこのページに戻ってください。

OpenTelemetry 第4回 : Fastly Fiddle をインストルメント化する

Andrew Betts

Principal Developer Advocate, Fastly

私たちは OpenTelemetry に大きな期待を寄せています。このブログシリーズでは、その理由と、Fastly の VCL サービスFastly の新しい Compute プラットフォームからテレメトリを生成する方法についてご紹介してきました。ただし OpenTelemetry は、スタックにあるすべてに追加されることでその真の価値を発揮します。そうすることで、一体どのようなメリットがあるのでしょうか?それを確かめるために今回、私たちは Fastly Fiddle 全体をインストルメント化してみました。

エッジで OpenTelemetry を生成できることがご理解いただけたと思います。しかし、スタック全体で統合された可観測性を得ることをまだ体験していない方は、OpenTelemetry の実装にどれほどの価値があるのか確信を持てないかもしれません。その価値を実証するには、システム全体をインストルメント化することによって得られる効果をお見せする必要があります。そこで今回の記事では、Fiddle をインストルメント化した方法と、それによってどのようなトレースとインサイトが得られるかについてご説明します。

Fiddle はブラウザベースの ReactJS アプリケーション、NodeJS バックエンド、Fastly VCL サービスで構成されており、他の複数の内部システムと Fastly サービスと通信します。複雑で理解しづらいアーキテクチャが採用されています。

ブラウザで Fiddle にアクセスして RUN ボタンをクリックすると、1つの HTTP リクエストが実行されます。これにより、React アプリケーション は最初の API コールを行って実行セッションを開始し、ID を取得します。次に、その ID を使用してサーバーから計測データを受信するストリームに接続します。その後、ある時点でこれ以上取得可能なデータがなくなる、すなわちセッションがタイムアウトし、実行終了となります。これが Fiddle 実行のフルライフサイクルです。

トレースは、(時間の)「スパン」の原理に基づいて動作し、ツリーの形式をとります (スパンは複数の子を持つことができますが、親はひとつのみであるため)。「ルートスパン」はタスク全体すなわちトランザクション全体で要した時間の範囲を示し、「子スパン」はより細かなアクティビティごとの内訳を示します。

私は、プロセスで使用されるプラットフォームのコンポーネント全体をカバーする実行のトレースを取得するために Fiddle にトレース機能を追加しました。それでは説明はこれぐらいにして、結果をお見せします。

ご覧のとおり、複数のフロントエンドリクエスト、エッジプロキシ、サーバー処理、データベースクエリ、内部のマイクロサービスへのインストルメント化コールを含む、Fiddle 実行全体が単一のインタラクティブなグラフとして視覚化されています。

では、実装方法を詳しく解説します。

テレメトリの初期化 (React で開始)

まずは「tracing.ts」という新しいファイルで React.JS フロントエンドのトレース設定を作成することから始めましょう。

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

これは、前回の記事で紹介された Compute でのセットアップとよく似ています。OpenTelemetry は一緒に使用する必要がある多くの個別のライブラリを提供しますが、最終的に複雑さの大部分はこのファイルに集約されています。以下は各パーツの役割です。

  • Resource : アプリケーションを定義します。

  • Exporter : シリアル化されたスパンデータをコレクターエンドポイントに送信します。

  • Span processor : スパンデータをシリアル化して Exporter に渡します。

  • Context manager : 現在有効なスパンをトラックします。これにより、インストルメント化されるアプリケーションのコンポーネントが互いを認識する必要がなくなります (ブラウザで非同期の処理を通じてコンテキストをトラックするため、OTel は zone-js を使用し、ZoneContextManager として表示しています)。

  • Provider : OpenTelemetry API の実装を登録するランタイムにフックします。

アプリケーションのコードが OTel API を呼び出してスパンを作成する際、プロバイダーはコンテキストマネージャーから取得したコンテキストを使用してスパンを作成し、スパンプロセッサーに渡します。スパンはスパンプロセッサーによってシリアル化され、エクスポートされます。

この仕組みはかなり複雑に見えますが、アプリケーションのどこからでも OpenTelemetry API を呼び出すことが可能で、次の要件を満たす必要があることを考えると、それも当然といえます。

  1. アプリケーションにトレース設定がない場合でもコールによってエラーが発生しない。

  2. これらのコールによって作成されたスパンは、親スパンや子スパンとは完全に独立して互いの中にネストされる。

これが重要な理由は、アプリケーションで OpenTelemetry コールを行うのが自分だけとは限らないためです。スパンの生成には3つの方法があります。

  1. 独自のコード : OTelAPI.tracer.startSpan(...) を使用して独自のコードで明示的に指示する。

  2. 依存関係 : アプリケーションで使用されている依存関係の作成者によって、OTelAPI.tracer.startSpan(...) コールがコードに含まれている (OpenTelemetry API がモジュールの依存関係としてリストされている)。

  3. インストルメント化モジュール : 一般的に HTTP コールなど、プラットフォームの標準機能にテレメトリを組み込むために、OpenTelemetry の「インストルメント化」モジュールをプロバイダーで登録します。

長期間必要なタスクに関連するスパンを明示的にコードに追加する場合、親スパンなどのコンテキストを手動で渡すこともできますが、依存関係 (2) で発生するオペレーションや、OpenTelemetry がインストルメント化モジュール (3) を通じてモニタリングする場合には役立ちません。これらのように自律的に作成されるスパンは、コンテキストマネージャーから自動的に有効なコンテキストを取得します。また、ビルトインされたテレメトリと一緒にリリースされる依存関係は、アプリケーションで OpenTelemetry が使用されていることをまったく認識しないため、OpenTelemetry API 自体は意図的に単なる型の定義となり、実装機能はありません。undefinedundefined  

コードにスパンを追加するのは、何か、または誰かが確認しようと思えばそれを確認できるという意味で、ある種の「検査ドア」を作成するようなものです。しかし、プロバイダーが登録されていないと、OpenTelemetry API は何もしません。

依存関係のいずれかがテレメトリを出力しているかどうかは、それを収集し、トレースをレンダリングして確認するまで分かりません。ただし、インストルメント化モジュールはトレース設定ファイルに手動で追加されます。ここでは Fiddle のブラウザアプリのフェッチ API をインストルメント化するのが目的なので、これを 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 });
        }
      },
    }),
  ],
});

これでテレメトリの設定が完了し、あらゆるフェッチのコールに加えて、スパンを作成するために OpenTelemetry API を呼び出すすべてのアプリケーションコードによって、プロバイダーの機能がトリガーされます。

React コードの周辺にスパンを追加する

最後に、独自のカスタムスパンを追加します。OpenTelemetry のプロバイダー設定を API から切り離す原理は、API を独自に使用する場合にも適用されます。そのため、実際、私たちのアプリケーションコードでは、tracing.ts から何もインポートする必要はありません。代わりに、独立して OpenTelemetry API を呼び出します。以下は、これを 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

};

これは、プロセスがより複雑になっていく可能性があります。たとえば OpenTelemetry のドキュメントでは、以下のコードが示すように、すべてのスパンでエラーをキャッチし、それらをスパンに割り当てることが推奨されています。

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

};

他にもすっきりしない部分があります。getTracer() をコールして、startActiveSpan のコールを可能にするオブジェクトを取得する必要がありますが、OpenTelemetry は必要な場合にのみこれを行うことを推奨しています。

一般的に、アプリケーションの他のすべての部分にトレーサーインスタンスをエクスポートするよりも、必要な場合にアプリケーションで getTracer をコールすることが推奨されます。これにより、他に必要な依存関係が存在する場合に、アプロケーションの厄介な読み込みの問題を回避できます。undefined

次に startActiveSpan: { attributes: { ... } } の2つ目の因数にご注目ください。この SpanOptions を使用して、スパンの「種類」のカスタマイズや、カスタム開始時間の提供などが可能です。多くの場合、カスタム属性を提供する必要があるだけなので、これらを attributes キー内にネストさせる必要があるのは好ましくありません。

これを少し抽象化する必要が出てきたように思えますが、その前にコンテキストが自動的にコンテキストマネージャーから検出されない複雑なエッジケースについて検討する必要があります。

手動によるコンテキストの伝播

Fiddle で実行がトリガーされる際、ルートスパンがコンポーネントツリーのかなり下の方で起こる一連の非同期アクティビティまでカバーする必要があります。以下の POST リクエストは <App> ルートコンポーネントで発生し、同じ実行の結果をストリームするリクエストは <Result> コンポーネントで生じます。そしてこれらを以下のようにネストしなければなりません。

これを行うには、新しいコンテキストを読み込んでそれをコンポーネントに渡すのが現時点で最善の方法のように思います。たとえば、高レベルの  コンポーネントでスパンとそれに関連する新しい有効なコンテキストを 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
});

その後、このステートが props を通じて子コンポーネントに渡され、子スパンをそれにアタッチして、有効なコンテキストが変わってもステートを使用することが可能です。

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

または、スパン自体を終了させることによってもこれが可能です。

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

ボイラープレートのように見えるこれらのようなシナリオでは、新しいスパンはコールバックのパラメーターの扱いですが、私たちは OTel API から context.active() をコールして新しいコンテキストを取得する必要があります。チャレンジ精神が湧きますね。

トレースコールの抽象化

そこで、ユーティリティ関数を作り、tracing.ts に追加することにしましたが、実際にはそれをエクスポートしてトレースしたい場所でそれを使用できるようにしました。

常にトレーサー名とスパン名を取得する関数、オプションで属性と親コンテキストのセット、そしてコールバック関数を使用したいと考えました。コールバックが2つの因数を要求する場合は、新しいスパンとコンテキストでコールし、スパン自体を終了することが想定されます。コールバックが因数を要求しない場合は、関数によって返された Promise が解決するとスパンを終了します。

以下は新しい 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;
    }
  });
}

これで、tracing.ts ファイルにあるモジュールをインポート/再エクスポートできます。

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

さらに、以下のようなシンプルなシナリオで traceSpan をコールすることができます。

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

};

コールバックで何もパラメーターを受け取らないため、スパンを明示的に終了せずに済むようになります。属性のネスト化やトレーサーの事前初期化の必要もなく、単に最初の因数としてトレーサー名を渡すことができます。そして、自動的にエラー処理が行われます。

より複雑なシナリオでも問題ありません。

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

};

このコールバックは2つの因数を受け入れるため、スパンは自動的に終了せず、終了させるかどうかは私次第です。つまり、React のステートフックまたはコンポーネントプロパティでスパンを維持し、後でアプリケーションの別の場所で終了させることができるということです。コンテキストにアクセスできるので、これを親として新しいスパンを明示的に開始することも可能です。

いずれの場合もトレースでエラー処理ができるので、新しいトレーサーインスタンスと (OpenTelemetry のドキュメントで推奨されているように)、最小限のボイラープレートを使用します。

フロントエンドのトレースに信頼性があるか

端的に言えば、答えは「ノー」です。トレースはすべて、HTTP を介して OTLP JSON を受け取るように設定された OpenTelemetry コレクターのインタンスに送られます。ここで、サイトの配信に使用するのと同じオリジンサーバーにトレースを送信できるよう、Fastly サービスの後ろにあるサーバーが公開されています。もちろん誰でもトレースデータをこのエンドポイントに提出できるので、それが問題な場合は、バックエンドからのスパンを含まないトレースをすべて破棄することで解決できます。

今のところ、遡及的にこれを行う方法しか思いつきませんが、コレクターまたはコレクターの前面にある Fastly サービスでこれを行うことができるような気がします。

NodeJS バックエンドのトレース

同じ traceSpan 関数を NodeJS で同様に使用できますが、トレースの設定に若干異なるライブラリを使う必要があるので、tracing.tstracing-utils.ts を分けています。

これで、サーバーサイドのトレース設定に、新しい tracing.ts ファイルを作成できます。

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

違いは明らかです。

  • FetchInstrumentation の代わりに getNodeAutoInstrumentations@opentelemetry/auto-instrumentations-node から使用します。これは Fastify や (Fiddle でも使用されている) Express.JS などの NodeJS サーバー フレームワークをインストルメント化するよくできたライブラリです。

  • WebTracerProvider の代わりに NodeTracerProvider を使用します。

この方法のメリットは、サーバーサイドの telemetry.ts が、クライアントサイドの React アプリで使用されているのとまったく同じカスタム traceSpan 関数をインポート/再エクスポートできる点です。

そのため、Express.JS バックエンドで、同じスタイルの traceSpan コールを追加できます。

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

フロントエンドの場合と同様に、非同期関数の Promise が解決されるときにスパンが終了して欲しくないケースがあります。その場合は、コールバックで2つの因数を受け取り、明示的に span.end() をコールするように設定できます。

エッジのサンドイッチ

フロントエンドの React アプリとバックエンドの NodeJS アプリは同じ分散システムの一部およびパーセルであり、Fastly はそれらの間に配置されています。このシリーズの第1回でご説明したように、OpenTelemetry の大きなメリットのひとつは、Fastly によってホストされたシステムのコンポーネントを同じアーキテクチャ全体の一部として認識できる点にあります。

シリーズの第2回と第3回では、OpenTelemetry に Fastly を追加する方法について詳しく説明しました。

この記事では、システムで Fastly サービスが Fastly 以外のコンポーネントと組み合わされる場合に、Fastly サービスを OpenTelemetry によって可視化することで得られるメリットを感じていただければと思います。

次のステップ

このブログ記事でお伝えしたかったことを十分にお見せできたと思いますが、実際には Fiddle のすべての部分をインストルメント化していません。Compute アプリケーションでは、コンパイラとランタイムのマイクロサービスが使用され、Compute と VCL の両方でバックエンドフェッチを検査するためにインストルメント化のプロキシが使われます。これらすべてをインストルメント化することで、OpenTelemetry によって得られる全体像をより豊かなものにすることができます。

皆さんが Fastly サービスに対して OpenTelemetry を使用しているか、また、得られたデータをどのように活用しているのか、とても興味があります。ぜひ、Twitter でお知らせください。


OpenTelemetry に関する全4回にわたるブログシリーズの記事をぜひご覧ください。