OpenTelemetry を Python の Decorator として定義する

2024-09-27

はじめに

OpenTelemetry を Python の Decorator として定義する方法について説明します。 本記事は Python、Google Cloud の オブザーバビリティ関連プロダクトの知識があることを前提としています。

OpenTelemetry とは、ロギング・トレーシング・メトリクスを出力しサービスのオブザーバビリティ (o11y) を高めやすくするためのフレームワークです。 本記事では特にトレーシングに焦点を当てて説明します。

もしオブザーバビリティについての概要を知りたい場合は、以前記述した Observability overview を参照してください。

Belong では、Google Cloud の Cloud Trace に最適化した、Go の社内ライブラリを作成し、各プロダクトで利用しています。 OpenTelemetry が Stable になる前からトレーシングを行っているため、このライブラリは OpenCensus を用いて構築されていますが、 Go の OpenTelemetry が Stable になって暫く経つので、近い将来移行したいと考えています。

一方、Python のサービスは簡単な社内ツールであったり、スクリプティングなどで使われており、共通したトレーシングの仕組みを導入する優先度は高くありませんでした。
しかし、最近 LLM で RAG を用いたサービスの本番化や、他にも継続的な o11y や信頼性が欲しいサービスが増えてきたため、 この機に各サービスで利用しやすい形で OpenTelemetry を用いたライブラリを作成することにしました。

OpenTelemetry の Decorator としての実装

背景

OpenTelemetry の導入は、基本的には公式ページの Getting Started に従えば問題ないですが、以下の条件を満たしたいと考えていました。

  1. Decorator を用いて関数ごとに Span を生成可能
  2. Google Cloud 環境に対応

この条件を満たすための実装を以下に示します。

実装

ライブラリ

今回の実装を実現するためにライブラリを用いています。

  • opentelemetry-api: 1.27.0
  • opentelemetry-sdk: 1.27.0
  • opentelemetry-exporter-gcp-trace: 1.7.0
  • opentelemetry-propagator-gcp: 1.7.0
  • opentelemetry-instrumentation: 0.48b0
  • opentelemetry-instrumentation-logging: 0.48b0
  • google-cloud-logging: 3.11.2
  • python-json-logger: 2.0.7

Decorator の定義

基本的なカスタムデコレータの記述については functools.wraps に記載があります。

以下の形でデコレータを定義すると、 @tracing アノテーションを関数に与えることで関数の呼び出し時に Span を生成することができます。 既存の Span が存在する場合は 子 Span として生成し、存在しない場合はルートの Span として生成します。

from functools import wraps
from opentelemetry import trace

tracer = trace.get_tracer_provider().get_tracer(__name__)

def tracing(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        parent_span = trace.get_current_span()
        with tracer.start_as_current_span(
            name=func.__qualname__, links=[trace.Link(parent_span.get_span_context())]
        ):
            return func(*args, **kwargs)

    return wrapper

初期化

Tracing を有効にするにはプログラムの立ち上がりで初期化を行う必要があります。

from opentelemetry import trace
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.cloud_trace_propagator import (
    CloudTraceFormatPropagator,
)

tracer_provider = TracerProvider()
# Google Cloud へバッチで Span を送信
processor = BatchSpanProcessor(CloudTraceSpanExporter())

# Google Cloud における X-Cloud-Trace-Context ヘッダの利用に必要
set_global_textmap(CloudTraceFormatPropagator())

tracer_provider.add_span_processor(processor)
trace.set_tracer_provider(tracer_provider)

# traceId と spanId をログするのに必要
LoggingInstrumentor().instrument()

ここでは以下のことを行いました

  1. TracerProvider を生成し、Google Cloud に Span を送信するように設定
  2. Google Cloud における分散トレーシング・ロギングのための Propagator を設定

また、Log を適切に出力するためには以下のように設定します。

import logging

def get_log_formatter():
    from pythonjsonlogger import jsonlogger

    # Google Cloud Logging に適したフォーマットへの変換を指示
    formatter = jsonlogger.JsonFormatter(
        "%(asctime)s %(levelname)s %(message)s %(otelTraceID)s %(otelSpanID)s %(otelTraceSampled)s",
        rename_fields={
            "levelname": "severity",
            "asctime": "timestamp",
            "otelTraceID": "logging.googleapis.com/trace",
            "otelSpanID": "logging.googleapis.com/spanId",
            "otelTraceSampled": "logging.googleapis.com/trace_sampled",
        },
        datefmt="%Y-%m-%dT%H:%M:%SZ",
    )
    return formatter


def get_log_handler():
    logHandler = logging.StreamHandler()
    formatter = get_log_formatter()
    logHandler.setFormatter(formatter)
    return logHandler

logging.basicConfig(
    level=logging.INFO,
    handlers=[get_log_handler()],
)

ここでは以下のことを行いました

  1. Google Cloud の LogEntry に適したフォーマットに変換
  2. logging モジュールの設定

このログのフォーマッタは 色々な書き方があります。 今回は JsonFormatter の作成時に値を与えましたが、インスタンス作成後に書き換えることも可能です。 自身の需要にあったフォーマットで設定してください。 OpenTelemetry と LogEntry のフィールド名のマッピングはいずれにせよ必要になると思います。

デコレーターの利用

最後に、@tracing デコレータを用いて関数に親子関係を持つ Span を生成する方法を示します。

@tracing
def my_parent_function():
    ...
    my_child_function()

@tracing
def my_child_function():
    do something...


以上の設定を行うことで、Python の関数呼び出しごとに Span を生成する Tracing を実現することができます。

まとめ

本記事では OpenTelemetry を Python の Decorator として定義する方法について説明しました。 これにより、Python の関数呼び出し毎に Span を生成することができ、サービスのオブザーバビリティを向上させることができます。

Belong では Python のスペシャリストや幅広い開発に興味があるソフトウェアエンジニア、 o11y に興味のあるエンジニアなど、様々なバックグラウンドを持つエンジニアを募集しています。 Belong でのソフトウェアの開発に興味がある方は、ぜひ エンジニアリングチーム紹介ページ をご覧いただき、お声がけください。

参考