T-CREATOR

htmx 運用 SLO 設計:サーバ応答・スワップ完了・履歴遷移の 3 指標を可視化

htmx 運用 SLO 設計:サーバ応答・スワップ完了・履歴遷移の 3 指標を可視化

htmx を本番環境で運用する際、従来の SPA(Single Page Application)とは異なる監視指標が必要になります。サーバサイド HTML の部分更新という独特のアーキテクチャにおいて、ユーザー体験を担保するには「サーバ応答」「スワップ完了」「履歴遷移」の 3 つの指標を可視化し、SLO(Service Level Objective)として管理することが重要です。

本記事では、htmx 特有のライフサイクルを深く理解し、それぞれの指標をどのように計測・可視化するかを具体的なコード例とともに解説します。

背景

htmx のライフサイクルと従来の監視手法の違い

htmx はサーバサイドレンダリングとクライアントサイドの部分更新を組み合わせた、ハイブリッドなアーキテクチャを採用しています。ユーザーのアクションに対して、サーバから受け取った HTML フラグメントを DOM に直接スワップするという動作は、SPA とも従来の MPA とも異なります。

この独特な動作フローにより、以下のような監視上の課題が生まれました。

mermaidflowchart TB
  user["ユーザー"] -->|クリック/フォーム送信| trigger["htmx トリガー"]
  trigger -->|HTTP リクエスト| server["サーバ"]
  server -->|HTML フラグメント| response["レスポンス受信"]
  response -->|DOM 操作| swap["スワップ完了"]
  swap -->|履歴 API| history["履歴更新"]
  history --> user

  style trigger fill:#e1f5ff
  style server fill:#fff4e1
  style swap fill:#e8f5e9
  style history fill:#f3e5f5

この図から分かるように、htmx では複数のステージが存在し、それぞれでパフォーマンスのボトルネックが発生する可能性があります。

SPA との監視指標の違い

SPA では主に JavaScript バンドルのロード時間、API レスポンス時間、クライアントサイドレンダリング時間が監視対象でした。しかし、htmx ではサーバサイドで HTML を生成するため、サーバサイドのレンダリング時間が直接ユーザー体験に影響します。

また、htmx は履歴管理を hx-push-url 属性で制御するため、履歴遷移のパフォーマンスも独立した指標として監視する必要があります。

#指標カテゴリSPAhtmx
1バンドルサイズ★★★
2API レスポンス★★★-
3サーバレンダリング-★★★
4DOM スワップ★★★
5履歴管理★★★★★

SLO による信頼性の可視化

SLO(Service Level Objective)は、サービスの信頼性を定量的に管理するための目標値です。htmx 運用においても、各ライフサイクルステージに対して SLO を設定することで、以下のメリットが得られます。

  • ユーザー体験の劣化を早期に検知できます
  • チーム全体で共通の品質基準を持てるでしょう
  • パフォーマンス改善の優先順位を決定しやすくなりますね

課題

htmx 特有の計測ポイントの把握

htmx のライフサイクルは、従来の Web アプリケーションとは異なる複数のイベントで構成されています。それぞれのイベントがどのタイミングで発火し、どのような情報を持っているかを正確に把握しなければ、適切な監視は実現できません。

htmx は豊富なイベントシステムを提供していますが、それゆえにどのイベントを監視すべきか判断が難しいという課題があります。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Browser as ブラウザ
  participant htmx as htmx ライブラリ
  participant Server as サーバ
  participant DOM as DOM

  User->>Browser: クリック
  Browser->>htmx: htmx:trigger
  htmx->>Server: HTTP リクエスト
  htmx->>Browser: htmx:beforeRequest
  Server->>htmx: HTML レスポンス
  htmx->>Browser: htmx:afterRequest
  htmx->>DOM: htmx:beforeSwap
  DOM->>DOM: DOM 更新
  htmx->>Browser: htmx:afterSwap
  htmx->>Browser: htmx:pushedIntoHistory
  Browser->>User: UI 更新完了

この図は、htmx リクエストのライフサイクル全体と各イベントの発火タイミングを示しています。計測には複数のイベントを組み合わせる必要があることが分かります。

3 つの指標の定義と測定方法

htmx 運用において重要な 3 つの指標を定義します。

サーバ応答時間

リクエスト送信から HTML レスポンス受信までの時間です。この指標には、ネットワーク遅延、サーバサイドレンダリング時間、データベースクエリ時間などが含まれます。

目標値の例:95 パーセンタイルで 500ms 以内

スワップ完了時間

レスポンス受信から DOM 更新完了までの時間です。大規模な HTML フラグメントのスワップや、複雑な DOM 操作が含まれる場合に遅延が発生します。

目標値の例:95 パーセンタイルで 100ms 以内

履歴遷移時間

履歴 API の呼び出しと状態更新にかかる時間です。hx-push-urlhx-replace-url が指定されている場合に計測対象となります。

目標値の例:95 パーセンタイルで 50ms 以内

エラーとタイムアウトの扱い

ネットワークエラーやサーバエラーが発生した場合、これらをどのように記録し、SLO 計算に含めるかという課題があります。エラーケースを除外してしまうと、実際のユーザー体験を正しく反映できません。

また、タイムアウトが発生した場合の処理も考慮する必要があります。htmx のデフォルトタイムアウトは 120 秒ですが、実用上はもっと短い時間でタイムアウトと判断すべきでしょう。

解決策

htmx イベントシステムの活用

htmx は、リクエストのライフサイクル全体を通じて詳細なイベントを発火します。これらのイベントをリスナーで捕捉し、タイムスタンプを記録することで、各指標を計測できます。

主要なイベントは以下のとおりです。

#イベント名発火タイミング取得可能な情報
1htmxリクエスト送信直前リクエスト URL、メソッド
2htmxレスポンス受信直後ステータスコード、レスポンス時間
3htmxDOM 更新前レスポンス HTML、ターゲット要素
4htmxDOM 更新後更新された要素
5htmx履歴 API 呼び出し後新しい URL

計測コードの実装

計測コードは、各 htmx イベントにリスナーを登録し、タイムスタンプを記録する形で実装します。計測データは、後で分析しやすいように構造化された形式で保存します。

基本的な計測クラスの設計

まず、計測データを管理するクラスを設計します。このクラスは、リクエストごとに一意の ID を生成し、各ステージのタイムスタンプを記録します。

typescript// 計測データの型定義
interface MetricData {
  requestId: string; // リクエスト一意ID
  url: string; // リクエストURL
  method: string; // HTTPメソッド
  startTime: number; // 開始時刻(performance.now())
  beforeRequestTime?: number; // リクエスト送信時刻
  afterRequestTime?: number; // レスポンス受信時刻
  beforeSwapTime?: number; // スワップ開始時刻
  afterSwapTime?: number; // スワップ完了時刻
  historyPushTime?: number; // 履歴更新時刻
  statusCode?: number; // HTTPステータスコード
  error?: string; // エラーメッセージ
}

型定義では、Optional Chaining 演算子(?)を使用して、イベントが発火しない場合にも対応できるようにしています。

計測マネージャークラスの実装

次に、計測データを管理するマネージャークラスを実装します。このクラスは、リクエストごとのメトリクスを追跡し、完了時に集計を行います。

typescript// 計測マネージャークラス
class HtmxMetricsManager {
  private metrics: Map<string, MetricData>;
  private completedMetrics: MetricData[];

  constructor() {
    this.metrics = new Map();
    this.completedMetrics = [];
  }

  // リクエストIDを生成
  private generateRequestId(): string {
    return `${Date.now()}-${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }

  // 新しい計測を開始
  startMetric(url: string, method: string): string {
    const requestId = this.generateRequestId();
    const metric: MetricData = {
      requestId,
      url,
      method,
      startTime: performance.now(),
    };
    this.metrics.set(requestId, metric);
    return requestId;
  }
}

この実装では、Mapを使用して進行中の計測データを管理し、完了した計測は配列に移動させることで、メモリ効率を最適化しています。

タイムスタンプ記録メソッドの実装

各イベントのタイムスタンプを記録するメソッドを追加します。これらのメソッドは、イベントリスナーから呼び出されます。

typescript  // リクエスト送信時刻を記録
  recordBeforeRequest(requestId: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.beforeRequestTime = performance.now();
    }
  }

  // レスポンス受信時刻を記録
  recordAfterRequest(requestId: string, statusCode: number): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.afterRequestTime = performance.now();
      metric.statusCode = statusCode;
    }
  }

  // スワップ開始時刻を記録
  recordBeforeSwap(requestId: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.beforeSwapTime = performance.now();
    }
  }

  // スワップ完了時刻を記録
  recordAfterSwap(requestId: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.afterSwapTime = performance.now();
    }
  }

  // 履歴更新時刻を記録
  recordHistoryPush(requestId: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.historyPushTime = performance.now();
    }
  }

各メソッドは、performance.now()を使用して高精度なタイムスタンプを記録します。この関数はミリ秒単位の精度を持ち、ブラウザのパフォーマンス計測に最適です。

エラー記録と計測完了処理

エラーが発生した場合の記録と、計測完了時の処理を実装します。

typescript  // エラーを記録
  recordError(requestId: string, error: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      metric.error = error;
      this.completeMetric(requestId);
    }
  }

  // 計測を完了し、完了済みリストに移動
  private completeMetric(requestId: string): void {
    const metric = this.metrics.get(requestId);
    if (metric) {
      this.completedMetrics.push(metric);
      this.metrics.delete(requestId);

      // メトリクスを外部システムに送信
      this.sendMetric(metric);
    }
  }

  // メトリクスを外部システムに送信
  private sendMetric(metric: MetricData): void {
    // ここで監視システムやログ集約サービスに送信
    // 例: DataDog, New Relic, Prometheus など
    console.log('Metric completed:', metric);
  }

完了した計測データは、監視システムへ送信されます。実際の運用では、バッチ処理や非同期送信を実装することで、ユーザー体験への影響を最小限に抑えられます。

イベントリスナーの登録

htmx のイベントシステムと計測マネージャーを連携させるため、イベントリスナーを登録します。リスナーは、DOM の準備が完了した後に設定する必要があります。

グローバル計測マネージャーの初期化

まず、グローバルスコープで計測マネージャーのインスタンスを作成します。

typescript// グローバル計測マネージャーインスタンス
const metricsManager = new HtmxMetricsManager();

// リクエストIDを一時的に保存するためのWeakMap
// イベント間でリクエストIDを共有するために使用
const requestIdMap = new WeakMap<Element, string>();

WeakMapを使用することで、DOM 要素が削除された際に自動的にメモリが解放されます。これにより、メモリリークを防げます。

beforeRequest イベントリスナー

リクエスト送信直前に発火するイベントをリスナーで捕捉し、計測を開始します。

typescript// beforeRequest イベント:計測開始
document.body.addEventListener(
  'htmx:beforeRequest',
  (event: Event) => {
    const customEvent = event as CustomEvent;
    const detail = customEvent.detail;

    // リクエスト情報を取得
    const url =
      detail.xhr.responseURL || detail.pathInfo.requestPath;
    const method = detail.xhr.method || 'GET';

    // 計測を開始し、リクエストIDを取得
    const requestId = metricsManager.startMetric(
      url,
      method
    );

    // リクエストIDを要素に紐付け
    const target = detail.target as Element;
    requestIdMap.set(target, requestId);

    // beforeRequestの時刻を記録
    metricsManager.recordBeforeRequest(requestId);
  }
);

このリスナーでは、CustomEventにキャストして詳細情報を取得しています。htmx のイベントは、detailプロパティに豊富な情報を含んでいます。

afterRequest イベントリスナー

レスポンス受信直後に発火するイベントで、サーバ応答時間を記録します。

typescript// afterRequest イベント:レスポンス受信時刻を記録
document.body.addEventListener(
  'htmx:afterRequest',
  (event: Event) => {
    const customEvent = event as CustomEvent;
    const detail = customEvent.detail;
    const target = detail.target as Element;

    // リクエストIDを取得
    const requestId = requestIdMap.get(target);
    if (!requestId) return;

    // ステータスコードを取得
    const statusCode = detail.xhr?.status || 0;

    // afterRequestの時刻を記録
    metricsManager.recordAfterRequest(
      requestId,
      statusCode
    );

    // エラーチェック
    if (!detail.successful) {
      const error = `HTTP ${statusCode}: ${
        detail.xhr?.statusText || 'Unknown error'
      }`;
      metricsManager.recordError(requestId, error);
      requestIdMap.delete(target);
    }
  }
);

エラーが発生した場合は、そこで計測を終了し、エラー情報を記録します。これにより、エラー率も SLO 計算に含められます。

beforeSwap と afterSwap イベントリスナー

DOM 更新のタイミングを記録するリスナーを実装します。

typescript// beforeSwap イベント:スワップ開始時刻を記録
document.body.addEventListener(
  'htmx:beforeSwap',
  (event: Event) => {
    const customEvent = event as CustomEvent;
    const detail = customEvent.detail;
    const target = detail.target as Element;

    const requestId = requestIdMap.get(target);
    if (!requestId) return;

    metricsManager.recordBeforeSwap(requestId);
  }
);

// afterSwap イベント:スワップ完了時刻を記録
document.body.addEventListener(
  'htmx:afterSwap',
  (event: Event) => {
    const customEvent = event as CustomEvent;
    const detail = customEvent.detail;
    const target = detail.target as Element;

    const requestId = requestIdMap.get(target);
    if (!requestId) return;

    metricsManager.recordAfterSwap(requestId);
  }
);

これらのイベントは、HTML フラグメントが DOM に適用される前後で発火します。大規模な DOM 操作のパフォーマンスを把握するために重要です。

pushedIntoHistory イベントリスナー

履歴 API の更新タイミングを記録します。このイベントは、hx-push-urlhx-replace-urlが指定されている場合にのみ発火します。

typescript// pushedIntoHistory イベント:履歴更新時刻を記録
document.body.addEventListener(
  'htmx:pushedIntoHistory',
  (event: Event) => {
    const customEvent = event as CustomEvent;
    const detail = customEvent.detail;
    const target = detail.target as Element;

    const requestId = requestIdMap.get(target);
    if (!requestId) return;

    metricsManager.recordHistoryPush(requestId);

    // 計測完了
    requestIdMap.delete(target);
  }
);

履歴更新が完了した時点で、一連の計測が終了します。履歴更新が不要なリクエストの場合は、afterSwapで計測を終了するようにロジックを調整する必要があります。

3 指標の算出ロジック

記録したタイムスタンプから、3 つの指標を算出するメソッドを実装します。

指標算出メソッドの追加

計測マネージャークラスに、指標を算出するメソッドを追加します。

typescript  // サーバ応答時間を算出(ms)
  calculateServerResponseTime(metric: MetricData): number | null {
    if (!metric.beforeRequestTime || !metric.afterRequestTime) {
      return null;
    }
    return metric.afterRequestTime - metric.beforeRequestTime;
  }

  // スワップ完了時間を算出(ms)
  calculateSwapCompletionTime(metric: MetricData): number | null {
    if (!metric.afterRequestTime || !metric.afterSwapTime) {
      return null;
    }
    return metric.afterSwapTime - metric.afterRequestTime;
  }

  // 履歴遷移時間を算出(ms)
  calculateHistoryTransitionTime(metric: MetricData): number | null {
    if (!metric.afterSwapTime || !metric.historyPushTime) {
      return null;
    }
    return metric.historyPushTime - metric.afterSwapTime;
  }

  // 総処理時間を算出(ms)
  calculateTotalTime(metric: MetricData): number | null {
    if (!metric.beforeRequestTime || !metric.historyPushTime) {
      // 履歴更新がない場合はafterSwapTimeを使用
      if (metric.afterSwapTime) {
        return metric.afterSwapTime - metric.beforeRequestTime;
      }
      return null;
    }
    return metric.historyPushTime - metric.beforeRequestTime;
  }

各メソッドは、必要なタイムスタンプが存在しない場合にnullを返します。これにより、部分的なデータでも安全に処理できます。

パーセンタイル計算の実装

SLO では、平均値ではなくパーセンタイル値(95 パーセンタイルや 99 パーセンタイルなど)を使用することが一般的です。これは、外れ値の影響を受けにくく、実際のユーザー体験をより正確に反映するためです。

typescript  // パーセンタイル値を計算
  calculatePercentile(values: number[], percentile: number): number | null {
    if (values.length === 0) return null;

    // 昇順にソート
    const sorted = values.slice().sort((a, b) => a - b);

    // パーセンタイルのインデックスを計算
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;

    return sorted[index];
  }

  // 指定期間の指標統計を取得
  getMetricStats(
    startTime: number,
    endTime: number
  ): {
    serverResponse: { p50: number | null; p95: number | null; p99: number | null };
    swapCompletion: { p50: number | null; p95: number | null; p99: number | null };
    historyTransition: { p50: number | null; p95: number | null; p99: number | null };
    totalRequests: number;
    errorCount: number;
  } {
    // 期間内のメトリクスをフィルタ
    const filteredMetrics = this.completedMetrics.filter(
      m => m.startTime >= startTime && m.startTime <= endTime
    );

    return {
      serverResponse: this.calculateMetricPercentiles(
        filteredMetrics,
        this.calculateServerResponseTime.bind(this)
      ),
      swapCompletion: this.calculateMetricPercentiles(
        filteredMetrics,
        this.calculateSwapCompletionTime.bind(this)
      ),
      historyTransition: this.calculateMetricPercentiles(
        filteredMetrics,
        this.calculateHistoryTransitionTime.bind(this)
      ),
      totalRequests: filteredMetrics.length,
      errorCount: filteredMetrics.filter(m => m.error).length,
    };
  }

パーセンタイル計算では、配列をソートしてから指定されたパーセンタイル位置の値を取得します。

メトリクスパーセンタイル算出の補助メソッド

各指標のパーセンタイル値を算出する補助メソッドを実装します。

typescript  // 指標のパーセンタイル値を算出
  private calculateMetricPercentiles(
    metrics: MetricData[],
    calculator: (metric: MetricData) => number | null
  ): { p50: number | null; p95: number | null; p99: number | null } {
    // 各メトリクスから指標値を抽出
    const values = metrics
      .map(calculator)
      .filter((v): v is number => v !== null);

    if (values.length === 0) {
      return { p50: null, p95: null, p99: null };
    }

    return {
      p50: this.calculatePercentile(values, 50),
      p95: this.calculatePercentile(values, 95),
      p99: this.calculatePercentile(values, 99),
    };
  }

この補助メソッドにより、各指標に対して統一的なパーセンタイル計算が可能になります。

監視システムとの統合

計測データを、実際の監視システムに送信する実装を行います。ここでは、一般的な監視サービスとの統合例を示します。

バッチ送信の実装

リアルタイムでメトリクスを送信すると、ネットワークオーバーヘッドが大きくなります。そこで、一定期間ごとにまとめて送信するバッチ処理を実装します。

typescriptclass HtmxMetricsReporter {
  private manager: HtmxMetricsManager;
  private batchInterval: number;
  private intervalId: number | null;

  constructor(
    manager: HtmxMetricsManager,
    batchIntervalMs: number = 30000
  ) {
    this.manager = manager;
    this.batchInterval = batchIntervalMs;
    this.intervalId = null;
  }

  // バッチ送信を開始
  start(): void {
    if (this.intervalId !== null) return;

    this.intervalId = window.setInterval(() => {
      this.sendBatch();
    }, this.batchInterval);
  }

  // バッチ送信を停止
  stop(): void {
    if (this.intervalId !== null) {
      window.clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }
}

バッチ送信により、ネットワークリクエストの回数を削減し、パフォーマンスへの影響を最小化できます。

メトリクス送信の実装

集計したメトリクスを、監視システムの API に送信します。

typescript  // メトリクスをバッチ送信
  private async sendBatch(): Promise<void> {
    // 現在時刻から過去30秒のメトリクスを取得
    const endTime = performance.now();
    const startTime = endTime - this.batchInterval;

    const stats = this.manager.getMetricStats(startTime, endTime);

    // メトリクスが存在しない場合はスキップ
    if (stats.totalRequests === 0) return;

    // 監視システムに送信するペイロードを構築
    const payload = {
      timestamp: Date.now(),
      metrics: {
        'htmx.server_response.p50': stats.serverResponse.p50,
        'htmx.server_response.p95': stats.serverResponse.p95,
        'htmx.server_response.p99': stats.serverResponse.p99,
        'htmx.swap_completion.p50': stats.swapCompletion.p50,
        'htmx.swap_completion.p95': stats.swapCompletion.p95,
        'htmx.swap_completion.p99': stats.swapCompletion.p99,
        'htmx.history_transition.p50': stats.historyTransition.p50,
        'htmx.history_transition.p95': stats.historyTransition.p95,
        'htmx.history_transition.p99': stats.historyTransition.p99,
        'htmx.total_requests': stats.totalRequests,
        'htmx.error_count': stats.errorCount,
        'htmx.error_rate': stats.totalRequests > 0
          ? stats.errorCount / stats.totalRequests
          : 0,
      },
    };

    // 監視システムのAPIエンドポイントに送信
    await this.sendToMonitoringSystem(payload);
  }

  // 監視システムAPIへの送信
  private async sendToMonitoringSystem(payload: any): Promise<void> {
    try {
      // 実際の監視システムのエンドポイントに置き換える
      const response = await fetch('/api/metrics', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        console.error('Failed to send metrics:', response.statusText);
      }
    } catch (error) {
      console.error('Error sending metrics:', error);
    }
  }

この実装では、30 秒ごとにメトリクスを集計し、監視システムの API に送信しています。エラーハンドリングも適切に行うことで、監視システムへの送信失敗がユーザー体験に影響しないようにしています。

初期化とセットアップ

最後に、アプリケーション起動時に計測システムを初期化するコードを実装します。

typescript// アプリケーション起動時の初期化
document.addEventListener('DOMContentLoaded', () => {
  // 計測マネージャーとレポーターを初期化
  const metricsManager = new HtmxMetricsManager();
  const metricsReporter = new HtmxMetricsReporter(
    metricsManager,
    30000
  );

  // バッチ送信を開始
  metricsReporter.start();

  // ページアンロード時にバッチ送信を停止
  window.addEventListener('beforeunload', () => {
    metricsReporter.stop();
  });

  console.log('htmx metrics monitoring initialized');
});

これで、htmx アプリケーションの起動と同時に、自動的にメトリクス計測が開始されます。

具体例

サンプルアプリケーションでの実装

実際の htmx アプリケーションに、今回実装した計測システムを組み込む例を示します。

HTML 構造

htmx の属性を使用した基本的な HTML 構造です。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>htmx SLO モニタリングデモ</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <script src="/js/htmx-metrics.js"></script>
  </head>
  <body>
    <div id="app">
      <h1>商品一覧</h1>

      <!-- サーバから商品リストを取得してスワップ -->
      <button
        hx-get="/api/products"
        hx-target="#product-list"
        hx-swap="innerHTML"
        hx-push-url="true"
      >
        商品を読み込む
      </button>

      <div id="product-list">
        <!-- ここに商品リストが表示されます -->
      </div>
    </div>
  </body>
</html>

この HTML では、ボタンクリックで商品リストを取得し、#product-list要素にスワップします。hx-push-urlにより、履歴にも記録されます。

サーバサイド実装例(Node.js + Express)

サーバサイドでは、意図的に遅延を入れることで、計測システムが正しく動作することを確認できます。

javascriptconst express = require('express');
const app = express();

// 静的ファイルの提供
app.use(express.static('public'));

// 商品リストAPI
app.get('/api/products', async (req, res) => {
  // サーバサイドレンダリングのシミュレーション(200ms)
  await new Promise((resolve) => setTimeout(resolve, 200));

  const products = [
    { id: 1, name: 'ノートPC', price: 120000 },
    { id: 2, name: 'マウス', price: 3000 },
    { id: 3, name: 'キーボード', price: 8000 },
  ];

  // HTMLフラグメントを生成
  const html = `
    <ul>
      ${products
        .map(
          (p) => `
        <li>
          <strong>${p.name}</strong> - ${p.price}円
        </li>
      `
        )
        .join('')}
    </ul>
  `;

  res.send(html);
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

この実装では、200ms の遅延を入れることで、サーバ応答時間の計測をシミュレートしています。

ダッシュボードでの可視化例

計測したメトリクスを、ダッシュボードで可視化する方法を説明します。一般的な監視ツール(Grafana、DataDog、New Relic など)では、以下のようなクエリでグラフを作成できます。

Grafana での可視化例

Grafana を使用する場合、Prometheus などのデータソースと連携します。以下は、Prometheus クエリの例です。

promql# サーバ応答時間の95パーセンタイル
histogram_quantile(0.95,
  rate(htmx_server_response_duration_seconds_bucket[5m])
)

# スワップ完了時間の95パーセンタイル
histogram_quantile(0.95,
  rate(htmx_swap_completion_duration_seconds_bucket[5m])
)

# 履歴遷移時間の95パーセンタイル
histogram_quantile(0.95,
  rate(htmx_history_transition_duration_seconds_bucket[5m])
)

# エラー率
rate(htmx_error_count[5m]) / rate(htmx_total_requests[5m])

これらのクエリにより、リアルタイムで SLO 達成状況を監視できます。

ダッシュボードのパネル構成例

効果的なダッシュボードは、以下のようなパネル構成で作成します。

#パネル名表示内容アラート条件
1サーバ応答時間P50/P95/P99 の時系列グラフP95 > 500ms で警告
2スワップ完了時間P50/P95/P99 の時系列グラフP95 > 100ms で警告
3履歴遷移時間P50/P95/P99 の時系列グラフP95 > 50ms で警告
4総処理時間P50/P95/P99 の時系列グラフP95 > 650ms で警告
5エラー率エラー率の時系列グラフエラー率 > 1% で警告
6リクエスト数単位時間あたりのリクエスト数-

可視化の実装例(Chart.js)

シンプルなダッシュボードをクライアントサイドで実装する例も示します。

typescriptimport Chart from 'chart.js/auto';

class MetricsDashboard {
  private charts: Map<string, Chart>;

  constructor() {
    this.charts = new Map();
    this.initializeCharts();
  }

  // チャートを初期化
  private initializeCharts(): void {
    // サーバ応答時間チャート
    this.createChart(
      'serverResponseChart',
      'サーバ応答時間 (ms)',
      [
        { label: 'P50', color: 'rgb(75, 192, 192)' },
        { label: 'P95', color: 'rgb(255, 159, 64)' },
        { label: 'P99', color: 'rgb(255, 99, 132)' },
      ]
    );

    // スワップ完了時間チャート
    this.createChart(
      'swapCompletionChart',
      'スワップ完了時間 (ms)',
      [
        { label: 'P50', color: 'rgb(75, 192, 192)' },
        { label: 'P95', color: 'rgb(255, 159, 64)' },
        { label: 'P99', color: 'rgb(255, 99, 132)' },
      ]
    );

    // 履歴遷移時間チャート
    this.createChart(
      'historyTransitionChart',
      '履歴遷移時間 (ms)',
      [
        { label: 'P50', color: 'rgb(75, 192, 192)' },
        { label: 'P95', color: 'rgb(255, 159, 64)' },
        { label: 'P99', color: 'rgb(255, 99, 132)' },
      ]
    );
  }
}

Chart.js を使用することで、リアルタイムでメトリクスを視覚化できます。

チャート作成の補助メソッド

typescript  // チャートを作成
  private createChart(
    canvasId: string,
    title: string,
    datasets: Array<{ label: string; color: string }>
  ): void {
    const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    if (!canvas) return;

    const chart = new Chart(canvas, {
      type: 'line',
      data: {
        labels: [],
        datasets: datasets.map(ds => ({
          label: ds.label,
          data: [],
          borderColor: ds.color,
          backgroundColor: ds.color + '33',
          tension: 0.1,
        })),
      },
      options: {
        responsive: true,
        plugins: {
          title: {
            display: true,
            text: title,
          },
        },
        scales: {
          y: {
            beginAtZero: true,
          },
        },
      },
    });

    this.charts.set(canvasId, chart);
  }

  // データを更新
  updateChart(
    canvasId: string,
    timestamp: string,
    values: number[]
  ): void {
    const chart = this.charts.get(canvasId);
    if (!chart) return;

    // 最大50データポイントまで保持
    if (chart.data.labels!.length >= 50) {
      chart.data.labels!.shift();
      chart.data.datasets.forEach(ds => ds.data.shift());
    }

    chart.data.labels!.push(timestamp);
    chart.data.datasets.forEach((ds, index) => {
      ds.data.push(values[index]);
    });

    chart.update();
  }

これらのメソッドにより、定期的にメトリクスを取得してチャートを更新できます。

パフォーマンス改善の実例

計測システムにより、実際にどのようなパフォーマンス問題を検出し、改善できるかを示します。

ケース 1:サーバ応答時間の改善

ある日、ダッシュボードでサーバ応答時間の P95 が 500ms から 800ms に悪化していることに気づきました。調査の結果、データベースクエリが非効率になっていることが判明しました。

typescript// 改善前:N+1クエリ問題
async function getProductsWithReviews() {
  const products = await db.query('SELECT * FROM products');

  for (const product of products) {
    // 各商品ごとにクエリを実行(N+1問題)
    product.reviews = await db.query(
      'SELECT * FROM reviews WHERE product_id = ?',
      [product.id]
    );
  }

  return products;
}

この実装では、商品数に比例してクエリ数が増加し、応答時間が悪化していました。

typescript// 改善後:JOINを使用した単一クエリ
async function getProductsWithReviews() {
  // 単一のJOINクエリで全データを取得
  const rows = await db.query(`
    SELECT
      p.id, p.name, p.price,
      r.id as review_id, r.rating, r.comment
    FROM products p
    LEFT JOIN reviews r ON p.id = r.product_id
  `);

  // 結果を商品ごとにグループ化
  const productsMap = new Map();
  for (const row of rows) {
    if (!productsMap.has(row.id)) {
      productsMap.set(row.id, {
        id: row.id,
        name: row.name,
        price: row.price,
        reviews: [],
      });
    }

    if (row.review_id) {
      productsMap.get(row.id).reviews.push({
        id: row.review_id,
        rating: row.rating,
        comment: row.comment,
      });
    }
  }

  return Array.from(productsMap.values());
}

改善後、サーバ応答時間の P95 は 300ms まで改善されました。このように、計測システムによりパフォーマンス劣化を早期に検出し、迅速に対応できます。

ケース 2:スワップ完了時間の改善

大規模な HTML フラグメントをスワップする際、スワップ完了時間が目標の 100ms を大幅に超える 150ms になっていました。原因を調査したところ、不要な DOM 要素が多数含まれていることが判明しました。

html<!-- 改善前:冗長なDOM構造 -->
<div class="product-list">
  <div class="product-wrapper">
    <div class="product-container">
      <div class="product-item">
        <div class="product-header">
          <div class="product-title">
            <h3>商品名</h3>
          </div>
        </div>
        <div class="product-body">
          <div class="product-price">
            <span class="price-value">1000円</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

不要なラッパー要素を削除し、DOM 構造をシンプルにしました。

html<!-- 改善後:シンプルなDOM構造 -->
<ul class="product-list">
  <li class="product-item">
    <h3>商品名</h3>
    <span class="price">1000円</span>
  </li>
</ul>

改善後、スワップ完了時間の P95 は 70ms まで改善され、目標を達成できました。

ケース 3:履歴遷移時間の最適化

履歴遷移時間が目標の 50ms を超え、80ms になっていることに気づきました。調査の結果、不要な履歴エントリが追加されていることが判明しました。

html<!-- 改善前:全てのリクエストで履歴を更新 -->
<button
  hx-get="/api/products"
  hx-target="#product-list"
  hx-push-url="true"
>
  商品を読み込む
</button>

<button
  hx-get="/api/products/sort/price"
  hx-target="#product-list"
  hx-push-url="true"
>
  価格順に並び替え
</button>

並び替えなどの一時的な操作では、履歴更新が不要であることに気づきました。

html<!-- 改善後:必要な場合のみ履歴を更新 -->
<button
  hx-get="/api/products"
  hx-target="#product-list"
  hx-push-url="true"
>
  商品を読み込む
</button>

<button
  hx-get="/api/products/sort/price"
  hx-target="#product-list"
  hx-push-url="false"
>
  価格順に並び替え
</button>

改善後、必要な履歴遷移のみが実行されるようになり、履歴遷移時間の P95 は 40ms まで改善されました。

リアルタイム監視とアラート設定

メトリクスが目標値を超えた場合に、自動的にアラートを発報する仕組みを実装します。

アラートマネージャーの実装

typescriptinterface AlertRule {
  metric: string; // 監視対象メトリクス
  threshold: number; // 閾値
  duration: number; // 持続時間(ms)
  severity: 'warning' | 'critical'; // 深刻度
}

class AlertManager {
  private rules: AlertRule[];
  private alertStates: Map<
    string,
    { triggeredAt: number | null }
  >;

  constructor() {
    this.rules = [];
    this.alertStates = new Map();
  }

  // アラートルールを追加
  addRule(rule: AlertRule): void {
    this.rules.push(rule);
    this.alertStates.set(rule.metric, {
      triggeredAt: null,
    });
  }

  // メトリクスをチェックしてアラートを発報
  checkMetrics(metrics: Record<string, number>): void {
    for (const rule of this.rules) {
      const value = metrics[rule.metric];
      if (value === undefined) continue;

      const state = this.alertStates.get(rule.metric);
      if (!state) continue;

      if (value > rule.threshold) {
        // 閾値を超えている
        if (state.triggeredAt === null) {
          state.triggeredAt = Date.now();
        } else if (
          Date.now() - state.triggeredAt >=
          rule.duration
        ) {
          // 持続時間を超えたのでアラートを発報
          this.fireAlert(rule, value);
        }
      } else {
        // 閾値を下回ったのでリセット
        state.triggeredAt = null;
      }
    }
  }
}

このアラートマネージャーにより、メトリクスが一時的に閾値を超えた場合は無視し、持続時間を超えた場合のみアラートを発報できます。

アラート発報処理の実装

typescript  // アラートを発報
  private fireAlert(rule: AlertRule, value: number): void {
    const message = `[${rule.severity.toUpperCase()}] ${rule.metric} exceeded threshold: ${value} > ${rule.threshold}`;

    console.error(message);

    // 外部アラートシステムに通知
    this.sendAlertNotification({
      metric: rule.metric,
      value,
      threshold: rule.threshold,
      severity: rule.severity,
      timestamp: Date.now(),
    });
  }

  // アラート通知を送信
  private async sendAlertNotification(alert: any): Promise<void> {
    try {
      // Slack、PagerDuty、メールなどに通知
      await fetch('/api/alerts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(alert),
      });
    } catch (error) {
      console.error('Failed to send alert notification:', error);
    }
  }

実際の運用では、Slack やメール、PagerDuty などのアラートシステムと連携することで、迅速な対応が可能になります。

アラートルールの設定例

typescript// アプリケーション起動時にアラートルールを設定
const alertManager = new AlertManager();

// サーバ応答時間のアラート
alertManager.addRule({
  metric: 'htmx.server_response.p95',
  threshold: 500, // 500ms超えで警告
  duration: 60000, // 1分間持続
  severity: 'warning',
});

alertManager.addRule({
  metric: 'htmx.server_response.p95',
  threshold: 1000, // 1000ms超えで重大
  duration: 30000, // 30秒間持続
  severity: 'critical',
});

// スワップ完了時間のアラート
alertManager.addRule({
  metric: 'htmx.swap_completion.p95',
  threshold: 100, // 100ms超えで警告
  duration: 60000,
  severity: 'warning',
});

// エラー率のアラート
alertManager.addRule({
  metric: 'htmx.error_rate',
  threshold: 0.01, // 1%超えで警告
  duration: 30000,
  severity: 'warning',
});

alertManager.addRule({
  metric: 'htmx.error_rate',
  threshold: 0.05, // 5%超えで重大
  duration: 10000,
  severity: 'critical',
});

これらのアラートルールにより、パフォーマンス劣化やエラー増加を早期に検知し、迅速に対応できるようになります。

まとめ

htmx アプリケーションの運用において、適切な SLO 設計と監視体制の構築は非常に重要です。本記事では、htmx 特有のライフサイクルに基づいた 3 つの重要指標(サーバ応答時間、スワップ完了時間、履歴遷移時間)を可視化する方法を解説しました。

重要なポイント

htmx のイベントシステムを活用することで、詳細なパフォーマンス計測が実現できます。各ライフサイクルステージのタイムスタンプを記録し、パーセンタイル値を算出することで、実際のユーザー体験を正確に把握できるでしょう。

バッチ送信や非同期処理を適切に実装することで、計測システム自体がパフォーマンスに与える影響を最小限に抑えられます。また、アラートシステムとの統合により、問題の早期発見と迅速な対応が可能になりますね。

今後の展開

本記事で紹介した基本的な監視システムをベースに、以下のような拡張も検討できます。

  • ユーザーセグメント別の分析(デバイス、地域、ブラウザなど)
  • リクエスト URL ごとの詳細分析
  • A/B テスト時のパフォーマンス比較
  • 機械学習による異常検知

htmx の導入により、サーバサイドレンダリングのシンプルさと SPA の快適さを両立できます。適切な監視体制を構築することで、その利点を最大限に活かした高品質な Web アプリケーションを提供できるでしょう。

関連リンク