T-CREATOR

Pinia 可観測性の作り方:DevTools × OpenTelemetry で変更を可視化する

Pinia 可観測性の作り方:DevTools × OpenTelemetry で変更を可視化する

Vue.js や Nuxt.js でグローバルな状態管理を行う際、Pinia は非常に人気のあるライブラリです。しかし、アプリケーションが大規模化するにつれて「いつ、どこで、どのような状態変更が起きたのか」を追跡することが難しくなります。そこで注目したいのが、DevTools と OpenTelemetry を組み合わせた可観測性の実装です。この記事では、Pinia の状態変更を可視化し、パフォーマンスやデバッグを劇的に改善する方法をご紹介します。

背景

Pinia は Vue 3 の Composition API に完全対応した次世代の状態管理ライブラリとして、Vuex の後継として広く採用されています。シンプルな API と TypeScript の強力なサポートにより、開発体験が大幅に向上しました。

しかし、実運用では以下のような課題に直面することが少なくありません。

Pinia の基本構造

Pinia は Store という単位で状態を管理し、各 Store は以下の要素で構成されます。

  • State: アプリケーションの状態を保持
  • Getters: 状態から派生した値を計算
  • Actions: 状態を変更するメソッド

以下の図は、Pinia の基本的なアーキテクチャを示しています。

mermaidflowchart TB
  component["Vue コンポーネント"]
  store["Pinia Store"]
  state["State<br/>(状態)"]
  getters["Getters<br/>(算出値)"]
  actions["Actions<br/>(変更処理)"]

  component -->|状態参照| state
  component -->|算出値参照| getters
  component -->|アクション呼び出し| actions
  actions -->|状態更新| state
  state -->|依存| getters
  getters -->|リアクティブ| component

この構造により、Vue コンポーネントから直接 Store の状態にアクセスし、Actions を通じて状態を更新できます。

なぜ可観測性が必要なのか

アプリケーションが成長するにつれて、以下のような問題が顕在化します。

  • 複数の Store が相互に依存し、状態変更の流れが追いづらい
  • パフォーマンスのボトルネックがどこにあるのか特定できない
  • バグ発生時に、どの Action がどのタイミングで呼ばれたのか不明
  • ユーザー操作と状態変更の因果関係が見えにくい

こうした課題を解決するために、可観測性(Observability) の概念が重要になってきます。

課題

Pinia を使った開発では、以下のような具体的な課題が発生します。

1. 状態変更の追跡が困難

複数のコンポーネントから同じ Store にアクセスすると、どのコンポーネントがいつ状態を変更したのかを把握するのが難しくなります。

typescript// userStore.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    age: 0,
    isLoggedIn: false,
  }),
  actions: {
    updateUser(name: string, age: number) {
      this.name = name;
      this.age = age;
    },
    login() {
      this.isLoggedIn = true;
    },
  },
});

上記のような Store があった場合、複数のコンポーネントから updateUser が呼ばれると、変更の追跡が困難です。

2. パフォーマンスボトルネックの特定

どの Getter や Action が処理時間を消費しているのか、標準の Pinia だけでは測定できません。

typescript// productStore.ts
import { defineStore } from 'pinia';

export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
  }),
  getters: {
    // この Getter が重い処理かもしれない
    expensiveCalculation: (state) => {
      return state.products.map((p) => {
        // 複雑な計算処理
        return heavyComputation(p);
      });
    },
  },
});

3. デバッグ時の情報不足

本番環境でエラーが発生した際、「どの順番で Action が実行されたか」「そのときの State はどうだったか」といった情報が不足しがちです。

以下の図は、可観測性がない場合の課題を示しています。

mermaidflowchart LR
  user["ユーザー操作"]
  comp1["コンポーネント A"]
  comp2["コンポーネント B"]
  comp3["コンポーネント C"]
  store["Pinia Store"]
  unknown["❓<br/>どこで変更?<br/>いつ変更?<br/>なぜ変更?"]

  user --> comp1
  user --> comp2
  user --> comp3
  comp1 -.->|状態変更| store
  comp2 -.->|状態変更| store
  comp3 -.->|状態変更| store
  store --> unknown

このように、状態変更の発生源や理由が不透明になってしまいます。

解決策

これらの課題を解決するために、DevToolsOpenTelemetry を組み合わせた可観測性の仕組みを実装します。

DevTools による開発時の可視化

Vue DevTools には Pinia 専用のタブが用意されており、以下の情報を確認できます。

  • 現在の Store の状態
  • Actions の実行履歴
  • State の変更タイムライン

これにより、開発時のデバッグが格段に楽になります。

OpenTelemetry による本番環境の監視

OpenTelemetry は、分散トレーシング・メトリクス・ログを統合的に扱うための標準規格です。Pinia と組み合わせることで、以下が実現できます。

  • トレース: Action の実行時間や呼び出し元を記録
  • メトリクス: 状態変更の頻度や Getter の計算時間を測定
  • ログ: 状態変更の詳細情報を記録

以下の図は、DevTools と OpenTelemetry を組み合わせた可観測性アーキテクチャを示しています。

mermaidflowchart TB
  user["ユーザー操作"]
  component["Vue コンポーネント"]
  pinia["Pinia Store"]
  plugin["Pinia プラグイン<br/>(計装層)"]
  devtools["Vue DevTools<br/>(開発環境)"]
  otel["OpenTelemetry<br/>(本番環境)"]
  backend["バックエンド<br/>(Jaeger/Prometheus)"]

  user --> component
  component --> pinia
  pinia --> plugin
  plugin --> devtools
  plugin --> otel
  otel --> backend

  style plugin fill:#e1f5ff
  style devtools fill:#fff3cd
  style otel fill:#d1ecf1

このアーキテクチャにより、開発環境では DevTools で即座にデバッグし、本番環境では OpenTelemetry で継続的に監視できます。

実装の基本方針

  1. Pinia プラグインの作成: 状態変更を検知し、計装する
  2. DevTools との連携: 開発時の可視化を強化
  3. OpenTelemetry の統合: トレースとメトリクスを記録
  4. バックエンドへの送信: Jaeger や Prometheus で分析

具体例

それでは、実際のコードを見ながら実装方法を解説していきます。

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。

bashyarn add pinia
yarn add @opentelemetry/api @opentelemetry/sdk-trace-web @opentelemetry/exporter-trace-otlp-http
yarn add --dev @vue/devtools-api

これらのパッケージにより、Pinia、OpenTelemetry、DevTools API が利用可能になります。

Pinia プラグインの作成

Pinia プラグインを作成し、Action の実行を監視します。

typescript// plugins/piniaObservability.ts
import { PiniaPluginContext } from 'pinia';
import { trace, Span } from '@opentelemetry/api';

// OpenTelemetry のトレーサーを取得
const tracer = trace.getTracer('pinia-store', '1.0.0');

export function piniaObservabilityPlugin(
  context: PiniaPluginContext
) {
  const { store, options } = context;

  // Store の名前を取得
  const storeName = options.id || 'unknown-store';

  return {};
}

まず、OpenTelemetry の tracer を初期化し、Store 名を取得します。

Action の計装

次に、各 Action の実行をトレースします。

typescript// plugins/piniaObservability.ts (続き)
export function piniaObservabilityPlugin(
  context: PiniaPluginContext
) {
  const { store, options } = context;
  const storeName = options.id || 'unknown-store';

  // 元の Actions を保存
  const originalActions = { ...store.$actions };

  // 各 Action をラップ
  Object.keys(originalActions).forEach((actionName) => {
    const originalAction = store[actionName];

    // Action をトレース付きでラップ
    store[actionName] = function (...args: any[]) {
      // トレースのスパンを開始
      const span = tracer.startSpan(
        `${storeName}.${actionName}`
      );

      try {
        // 元の Action を実行
        const result = originalAction.apply(this, args);

        // Promise の場合は完了を待つ
        if (result instanceof Promise) {
          return result.finally(() => span.end());
        }

        // 同期処理の場合は即座に終了
        span.end();
        return result;
      } catch (error) {
        // エラーを記録
        span.recordException(error as Error);
        span.end();
        throw error;
      }
    };
  });
}

このコードにより、すべての Action が OpenTelemetry のスパンとして記録されます。

State の変更を監視

State の変更を検知し、メトリクスとして記録します。

typescript// plugins/piniaObservability.ts (続き)
import { watch } from 'vue';

export function piniaObservabilityPlugin(
  context: PiniaPluginContext
) {
  const { store, options } = context;
  const storeName = options.id || 'unknown-store';

  // ... (Action の計装コードは省略) ...

  // State の変更を監視
  watch(
    () => store.$state,
    (newState, oldState) => {
      // 変更された項目を検出
      const changes = detectChanges(oldState, newState);

      // 各変更をログに記録
      changes.forEach((change) => {
        console.log(
          `[${storeName}] ${change.path}: ${change.oldValue}${change.newValue}`
        );

        // OpenTelemetry でメトリクスとして記録
        const meter = metrics.getMeter('pinia-store');
        const counter = meter.createCounter(
          `${storeName}.state.changes`
        );
        counter.add(1, { path: change.path });
      });
    },
    { deep: true }
  );
}

これにより、State のどのプロパティがいつ変更されたかを追跡できます。

typescript// plugins/piniaObservability.ts - 変更検出のヘルパー関数
function detectChanges(
  oldState: any,
  newState: any,
  path = ''
): Array<{ path: string; oldValue: any; newValue: any }> {
  const changes: Array<{
    path: string;
    oldValue: any;
    newValue: any;
  }> = [];

  // すべてのキーを取得
  const keys = new Set([
    ...Object.keys(oldState || {}),
    ...Object.keys(newState || {}),
  ]);

  keys.forEach((key) => {
    const currentPath = path ? `${path}.${key}` : key;
    const oldValue = oldState?.[key];
    const newValue = newState?.[key];

    // オブジェクトの場合は再帰的に比較
    if (
      typeof oldValue === 'object' &&
      typeof newValue === 'object' &&
      oldValue !== null &&
      newValue !== null
    ) {
      changes.push(
        ...detectChanges(oldValue, newValue, currentPath)
      );
    } else if (oldValue !== newValue) {
      changes.push({
        path: currentPath,
        oldValue,
        newValue,
      });
    }
  });

  return changes;
}

OpenTelemetry の初期化

アプリケーション起動時に OpenTelemetry を初期化します。

typescript// plugins/telemetry.ts
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { registerInstrumentations } from '@opentelemetry/instrumentation';

export function initTelemetry() {
  // トレースプロバイダーを作成
  const provider = new WebTracerProvider({
    resource: {
      attributes: {
        'service.name': 'my-vue-app',
        'service.version': '1.0.0',
      },
    },
  });

  // OTLP エクスポーターを設定
  const exporter = new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces', // バックエンドの URL
  });

  // スパンプロセッサーを追加
  provider.addSpanProcessor(
    new SimpleSpanProcessor(exporter)
  );

  // グローバルに登録
  provider.register();
}

この設定により、トレースデータが指定したバックエンドに送信されます。

Nuxt.js での統合

Nuxt.js プロジェクトで Pinia プラグインを登録します。

typescript// plugins/pinia.ts
import { defineNuxtPlugin } from '#app';
import { piniaObservabilityPlugin } from './piniaObservability';
import { initTelemetry } from './telemetry';

export default defineNuxtPlugin((nuxtApp) => {
  // OpenTelemetry を初期化
  if (process.client) {
    initTelemetry();
  }

  // Pinia インスタンスを取得
  const pinia = nuxtApp.$pinia;

  // プラグインを登録
  if (pinia) {
    pinia.use(piniaObservabilityPlugin);
  }
});

クライアントサイドでのみ OpenTelemetry を初期化し、Pinia にプラグインを追加します。

実際の Store での利用

作成したプラグインは、すべての Store に自動的に適用されます。

typescript// stores/cart.ts
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as Array<{
      id: string;
      name: string;
      price: number;
    }>,
    total: 0,
  }),

  getters: {
    itemCount: (state) => state.items.length,
    totalPrice: (state) =>
      state.items.reduce(
        (sum, item) => sum + item.price,
        0
      ),
  },

  actions: {
    // この Action は自動的にトレースされる
    addItem(item: {
      id: string;
      name: string;
      price: number;
    }) {
      this.items.push(item);
      this.total = this.totalPrice;
    },

    // この Action も同様にトレースされる
    removeItem(itemId: string) {
      const index = this.items.findIndex(
        (item) => item.id === itemId
      );
      if (index !== -1) {
        this.items.splice(index, 1);
        this.total = this.totalPrice;
      }
    },
  },
});

プラグインにより、addItemremoveItem が実行されると、自動的にトレースが記録されます。

DevTools での確認

Vue DevTools を開くと、Pinia タブで以下の情報を確認できます。

#項目説明
1Store 一覧登録されているすべての Store を表示
2State の現在値各 Store の State をリアルタイムで表示
3Actions の実行履歴どの Action がいつ実行されたかを記録
4State の変更履歴State がどのように変化したかを追跡
5Time Travel デバッグ過去の State に戻って動作を確認できる機能

以下の図は、DevTools と OpenTelemetry によるトレースフローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Comp as コンポーネント
  participant Store as Pinia Store
  participant Plugin as プラグイン
  participant Dev as DevTools
  participant OTel as OpenTelemetry

  User->>Comp: ボタンクリック
  Comp->>Store: addItem() 呼び出し
  Store->>Plugin: Action 実行前フック
  Plugin->>OTel: スパン開始
  Plugin->>Dev: Action 記録
  Store->>Store: State 更新
  Store->>Plugin: State 変更検知
  Plugin->>OTel: メトリクス記録
  Plugin->>Dev: State 変更通知
  Plugin->>OTel: スパン終了
  OTel-->>User: トレース送信 (バックエンド)
  Dev-->>User: DevTools 更新

このシーケンス図により、ユーザー操作から State 変更、トレース記録までの一連の流れが理解できます。

Jaeger でのトレース確認

バックエンドに Jaeger を使用している場合、以下のような情報を確認できます。

#項目説明
1トレース ID一連の操作を識別する一意の ID
2スパンの階層構造どの Action がどの順番で実行されたか
3実行時間各 Action の処理時間
4エラー情報例外が発生した場合のスタックトレース
5カスタム属性Store 名や引数などの追加情報

これにより、本番環境でのパフォーマンス問題やエラーを詳細に分析できます。

パフォーマンス測定の例

Getter の計算時間を測定する場合は、以下のように拡張できます。

typescript// plugins/piniaObservability.ts - Getter の計装
export function piniaObservabilityPlugin(
  context: PiniaPluginContext
) {
  const { store, options } = context;
  const storeName = options.id || 'unknown-store';

  // Getters を計装
  if (options.getters) {
    Object.keys(options.getters).forEach((getterName) => {
      const originalGetter = store[getterName];

      Object.defineProperty(store, getterName, {
        get() {
          const startTime = performance.now();
          const result = originalGetter;
          const duration = performance.now() - startTime;

          // 実行時間をメトリクスとして記録
          console.log(
            `[${storeName}] Getter "${getterName}" took ${duration.toFixed(
              2
            )}ms`
          );

          return result;
        },
      });
    });
  }
}

これにより、重い Getter を特定し、最適化の対象を明確にできます。

エラートラッキングの強化

Action 内でエラーが発生した場合、詳細な情報を記録します。

typescript// stores/user.ts - エラーハンドリング付き Action
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    loading: false,
    error: null as string | null,
  }),

  actions: {
    async fetchUser(userId: string) {
      this.loading = true;
      this.error = null;

      try {
        const response = await fetch(
          `/api/users/${userId}`
        );
        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }
        this.user = await response.json();
      } catch (error) {
        // エラーは自動的にトレースに記録される
        this.error = error.message;
        throw error;
      } finally {
        this.loading = false;
      }
    },
  },
});

プラグインにより、エラーが OpenTelemetry のスパンに記録され、バックエンドで分析できます。

まとめ

この記事では、Pinia の可観測性を実現するために DevTools と OpenTelemetry を組み合わせる方法を解説しました。

実現できたこと

#項目効果
1開発時の可視化DevTools で State 変更と Action をリアルタイム追跡
2本番環境の監視OpenTelemetry でトレースとメトリクスを記録
3パフォーマンス測定Action や Getter の実行時間を定量的に把握
4エラートラッキングエラー発生時の詳細な情報を記録
5デバッグの効率化Time Travel デバッグで過去の State を再現

導入のメリット

可観測性を導入することで、以下のようなメリットが得られます。

  • 問題の早期発見: パフォーマンス劣化やエラーを即座に検知できます
  • デバッグ時間の短縮: 状態変更の流れが明確になり、原因特定が容易になります
  • チーム開発の効率化: 状態管理の動作が可視化され、レビューが円滑になります
  • 本番環境の安心感: リアルタイムで監視でき、問題発生時に迅速に対応できます

次のステップ

さらに高度な可観測性を実現するには、以下のような拡張も検討できます。

  • カスタムメトリクスの追加: ビジネスロジック固有の指標を記録
  • アラートの設定: 異常な状態変更を検知して通知
  • ダッシュボードの構築: Grafana などで可視化
  • 分散トレーシング: API 呼び出しとの連携を追跡

Pinia の可観測性を実装することで、より堅牢で保守性の高いアプリケーションを構築できます。ぜひ、あなたのプロジェクトにも導入してみてください。

関連リンク