New Relic Core Web Vitals監視設定 - ユーザーエクスペリエンス最適化の実践

Core Web Vitalsは、Googleが定義したユーザーエクスペリエンスの品質を測定する重要な指標群で、検索ランキングの決定要因としても使用されています。New Relic Browser Agentは、これらの指標を自動的に収集し、詳細な分析とアラート機能を提供します。本記事では、効果的なCore Web Vitals監視の設定方法と最適化戦略を解説します。

Core Web Vitalsの理解

Core Web Vitalsは、Webページのユーザーエクスペリエンスを定量化する3つの主要指標で構成されています。

LCP(Largest Contentful Paint)

LCPは、ページの主要コンテンツの読み込み完了時間を測定します。ビューポート内で最も大きなコンテンツ要素(画像、動画、テキストブロック)がレンダリングされるまでの時間を追跡します。

良好な値の基準は2.5秒以下で、4.0秒を超えると改善が必要とされます。LCPの測定対象となる要素には、<img>要素、<svg>内の<image>要素、<video>要素(ポスター画像)、CSS background-imageを持つ要素、テキストノードを含むブロックレベル要素が含まれます。

INP(Interaction to Next Paint)

INPは、ページ上のすべてのインタラクション(クリック、タップ、キー入力)の応答時間を測定し、そのページでのインタラクションの全体的な応答性を評価します。この指標は、入力遅延、処理時間、および描画時間を含む完全なインタラクションレイテンシーを捉えます。

良好な値の基準は200ミリ秒以下で、500ミリ秒を超えると改善が必要です。INPは、ページ上で発生したすべてのインタラクションを考慮し、最も遅い応答時間(または98パーセンタイル値)を報告します。

CLS(Cumulative Layout Shift)

CLSは、ページの視覚的安定性を測定し、予期しないレイアウトの変化の累積スコアを算出します。画像の読み込み、広告の挿入、フォントの読み込みなどによる意図しないレイアウトシフトを検知します。

良好な値の基準は0.1以下で、0.25を超えると改善が必要とされます。CLSの計算では、影響分数(シフトした要素が占める画面の割合)と距離分数(シフト距離の割合)を掛け合わせてスコアを算出します。

New RelicでのCore Web Vitals自動監視

New Relic Browser Agentは、現代ブラウザのPerformance Observer APIを使用してCore Web Vitalsを自動的に測定します。

基本的な設定

javascript
// Core Web Vitals自動監視の有効化
window.NREUM.loader_config = {
  // 基本設定
  agent: {
    debug: false
  },
  
  // Core Web Vitals監視設定
  page_view_timing: {
    enabled: true,
    harvestTimeSeconds: 30
  },
  
  // パフォーマンス監視の詳細設定
  spa: {
    enabled: true,
    harvestTimeSeconds: 10
  }
};

詳細な監視実装

javascript
// 高度なCore Web Vitals監視クラス
class EnhancedCoreWebVitalsMonitor {
  constructor() {
    this.vitalsData = {
      lcp: null,
      inp: null,
      cls: 0,
      ttfb: null
    };
    
    this.setupVitalsObservers();
    this.setupAdditionalMetrics();
  }
  
  setupVitalsObservers() {
    // LCP監視
    this.observeLCP();
    
    // INP監視
    this.observeINP();
    
    // CLS監視
    this.observeCLS();
    
    // TTFB(Time to First Byte)監視
    this.observeTTFB();
  }
  
  observeLCP() {
    if (!('PerformanceObserver' in window)) return;
    
    try {
      const observer = new PerformanceObserver((entryList) => {
        const entries = entryList.getEntries();
        const lastEntry = entries[entries.length - 1];
        
        this.vitalsData.lcp = {
          value: lastEntry.startTime,
          element: this.getElementInfo(lastEntry.element),
          renderTime: lastEntry.renderTime || lastEntry.loadTime,
          loadTime: lastEntry.loadTime,
          size: lastEntry.size,
          url: lastEntry.url || null
        };
        
        this.reportMetric('LCP', this.vitalsData.lcp);
      });
      
      observer.observe({ type: 'largest-contentful-paint', buffered: true });
    } catch (e) {
      console.warn('LCP観測の設定に失敗しました:', e);
    }
  }
  
  observeINP() {
    if (!('PerformanceObserver' in window)) return;
    
    try {
      let interactionData = new Map();
      
      // Event Timing APIを使用してINPを測定
      const observer = new PerformanceObserver((entryList) => {
        entryList.getEntries().forEach((entry) => {
          // インタラクションIDでグループ化
          const interactionId = entry.interactionId;
          if (!interactionId) return;
          
          const existingEntry = interactionData.get(interactionId);
          if (!existingEntry || entry.duration > existingEntry.duration) {
            const inpValue = entry.duration;
            
            this.vitalsData.inp = {
              value: inpValue,
              inputDelay: entry.processingStart - entry.startTime,
              processingTime: entry.processingEnd - entry.processingStart,
              presentationDelay: entry.startTime + entry.duration - entry.processingEnd,
              eventType: entry.name,
              target: this.getElementInfo(entry.target),
              startTime: entry.startTime,
              interactionId: interactionId
            };
            
            interactionData.set(interactionId, this.vitalsData.inp);
            this.reportMetric('INP', this.vitalsData.inp);
          }
        });
      });
      
      observer.observe({ type: 'event', buffered: true });
    } catch (e) {
      console.warn('INP観測の設定に失敗しました:', e);
    }
  }
  
  observeCLS() {
    if (!('PerformanceObserver' in window)) return;
    
    try {
      let clsValue = 0;
      let clsEntries = [];
      
      const observer = new PerformanceObserver((entryList) => {
        entryList.getEntries().forEach((entry) => {
          // ユーザー入力による変化は除外
          if (!entry.hadRecentInput) {
            clsValue += entry.value;
            clsEntries.push({
              value: entry.value,
              startTime: entry.startTime,
              sources: entry.sources?.map(source => ({
                node: this.getElementInfo(source.node),
                previousRect: source.previousRect,
                currentRect: source.currentRect
              })) || []
            });
            
            this.vitalsData.cls = clsValue;
            
            this.reportMetric('CLS', {
              cumulativeValue: clsValue,
              shiftValue: entry.value,
              shiftCount: clsEntries.length,
              sources: entry.sources?.length || 0,
              timestamp: entry.startTime
            });
          }
        });
      });
      
      observer.observe({ type: 'layout-shift', buffered: true });
    } catch (e) {
      console.warn('CLS観測の設定に失敗しました:', e);
    }
  }
  
  observeTTFB() {
    // Navigation Timing APIを使用してTTFBを測定
    if (window.performance && window.performance.timing) {
      const timing = window.performance.timing;
      const ttfb = timing.responseStart - timing.requestStart;
      
      this.vitalsData.ttfb = {
        value: ttfb,
        dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
        tcpConnection: timing.connectEnd - timing.connectStart,
        serverResponse: timing.responseEnd - timing.responseStart
      };
      
      this.reportMetric('TTFB', this.vitalsData.ttfb);
    }
  }
  
  getElementInfo(element) {
    if (!element) return null;
    
    return {
      tagName: element.tagName?.toLowerCase(),
      id: element.id,
      className: element.className,
      src: element.src,
      alt: element.alt,
      href: element.href
    };
  }
  
  reportMetric(metricName, data) {
    if (typeof newrelic !== 'undefined') {
      // 基本的なメトリクス送信
      newrelic.addPageAction(`core_web_vitals_${metricName.toLowerCase()}`, {
        metric: metricName,
        value: data.value || data.cumulativeValue,
        grade: this.calculateGrade(metricName, data.value || data.cumulativeValue),
        deviceType: this.getDeviceType(),
        connectionType: this.getConnectionType(),
        viewport: this.getViewportInfo(),
        timestamp: Date.now(),
        ...data
      });
      
      // カスタム属性の設定
      newrelic.setCustomAttribute(`${metricName.toLowerCase()}_value`, data.value || data.cumulativeValue);
      newrelic.setCustomAttribute(`${metricName.toLowerCase()}_grade`, this.calculateGrade(metricName, data.value || data.cumulativeValue));
    }
  }
  
  calculateGrade(metric, value) {
    const thresholds = {
      LCP: { good: 2500, needsImprovement: 4000 },
      INP: { good: 200, needsImprovement: 500 },
      CLS: { good: 0.1, needsImprovement: 0.25 },
      TTFB: { good: 800, needsImprovement: 1800 }
    };
    
    const threshold = thresholds[metric];
    if (!threshold) return 'unknown';
    
    if (value <= threshold.good) return 'good';
    if (value <= threshold.needsImprovement) return 'needs_improvement';
    return 'poor';
  }
  
  getDeviceType() {
    const userAgent = navigator.userAgent;
    if (/tablet|ipad|playbook|silk/i.test(userAgent)) return 'tablet';
    if (/mobile|iphone|ipod|android|blackberry|opera|mini|windows\sce|palm|smartphone|iemobile/i.test(userAgent)) return 'mobile';
    return 'desktop';
  }
  
  getConnectionType() {
    if ('connection' in navigator) {
      return navigator.connection.effectiveType || 'unknown';
    }
    return 'unknown';
  }
  
  getViewportInfo() {
    return {
      width: window.innerWidth,
      height: window.innerHeight,
      ratio: window.devicePixelRatio || 1
    };
  }
  
  // パフォーマンス診断レポート生成
  generateDiagnosticReport() {
    const report = {
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      vitals: this.vitalsData,
      suggestions: this.generateOptimizationSuggestions()
    };
    
    if (typeof newrelic !== 'undefined') {
      newrelic.addPageAction('core_web_vitals_diagnostic_report', report);
    }
    
    return report;
  }
  
  generateOptimizationSuggestions() {
    const suggestions = [];
    
    // LCP最適化提案
    if (this.vitalsData.lcp && this.vitalsData.lcp.value > 2500) {
      suggestions.push({
        metric: 'LCP',
        issue: 'LCP値が推奨値を超えています',
        suggestions: [
          '画像の最適化(WebP形式の使用)',
          'Critical CSSの実装',
          'リソースの事前読み込み(preload)',
          'サーバーレスポンス時間の改善'
        ]
      });
    }
    
    // INP最適化提案
    if (this.vitalsData.inp && this.vitalsData.inp.value > 200) {
      suggestions.push({
        metric: 'INP',
        issue: 'INP値が推奨値を超えています',
        suggestions: [
          'JavaScriptの分割と遅延読み込み',
          'Event Handlerの最適化と軽量化',
          '重い処理のRequestIdleCallbackでの実行',
          'DOM操作の最適化とバッチ処理',
          'サードパーティスクリプトの最適化'
        ]
      });
    }
    
    // CLS最適化提案
    if (this.vitalsData.cls > 0.1) {
      suggestions.push({
        metric: 'CLS',
        issue: 'CLS値が推奨値を超えています',
        suggestions: [
          '画像とビデオのサイズ属性指定',
          'フォント表示の最適化(font-display: swap)',
          '広告やウィジェットの領域確保',
          'DOM要素の動的挿入の最適化'
        ]
      });
    }
    
    return suggestions;
  }
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
  window.coreWebVitalsMonitor = new EnhancedCoreWebVitalsMonitor();
});

最適化戦略の実装

Core Web Vitalsの改善には、各指標に特化した最適化アプローチが必要です。

LCP最適化戦略

javascript
// LCP要素の特定と最適化
class LCPOptimizer {
  static identifyLCPElements() {
    if (!('PerformanceObserver' in window)) return;
    
    const lcpElements = [];
    
    const observer = new PerformanceObserver((entryList) => {
      entryList.getEntries().forEach((entry) => {
        if (entry.element) {
          lcpElements.push({
            element: entry.element,
            startTime: entry.startTime,
            size: entry.size,
            tagName: entry.element.tagName,
            src: entry.element.src || entry.element.currentSrc,
            optimizationPriority: LCPOptimizer.calculateOptimizationPriority(entry)
          });
        }
      });
    });
    
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
    
    return lcpElements;
  }
  
  static calculateOptimizationPriority(entry) {
    let priority = 0;
    
    // 画像要素の場合
    if (entry.element.tagName === 'IMG') {
      priority += entry.element.loading === 'lazy' ? 10 : 0;
      priority += !entry.element.width || !entry.element.height ? 5 : 0;
      priority += entry.element.src && !entry.element.src.includes('webp') ? 3 : 0;
    }
    
    // テキスト要素の場合
    if (entry.element.tagName === 'P' || entry.element.tagName === 'H1') {
      const computedStyle = window.getComputedStyle(entry.element);
      priority += computedStyle.fontDisplay !== 'swap' ? 5 : 0;
    }
    
    return priority;
  }
  
  static implementPreloading(lcpElements) {
    lcpElements.forEach((element) => {
      if (element.src && element.optimizationPriority > 5) {
        const link = document.createElement('link');
        link.rel = 'preload';
        link.as = 'image';
        link.href = element.src;
        document.head.appendChild(link);
      }
    });
  }
}

INP最適化戦略

javascript
// インタラクション応答性の最適化
class INPOptimizer {
  static scheduleNonCriticalTasks(tasks) {
    if ('scheduler' in window && 'postTask' in window.scheduler) {
      // Scheduler API使用(実験的機能)
      tasks.forEach((task, index) => {
        window.scheduler.postTask(() => {
          task();
        }, {
          priority: 'background',
          delay: index * 100
        });
      });
    } else if ('requestIdleCallback' in window) {
      // requestIdleCallback使用
      const executeTask = (taskIndex = 0) => {
        requestIdleCallback((deadline) => {
          while (deadline.timeRemaining() > 0 && taskIndex < tasks.length) {
            tasks[taskIndex]();
            taskIndex++;
          }
          
          if (taskIndex < tasks.length) {
            executeTask(taskIndex);
          }
        });
      };
      
      executeTask();
    } else {
      // フォールバック: setTimeout使用
      tasks.forEach((task, index) => {
        setTimeout(task, index * 16);
      });
    }
  }
  
  static splitLongTask(longTask, chunkSize = 50) {
    return new Promise((resolve) => {
      const chunks = [];
      let index = 0;
      
      const processChunk = () => {
        const startTime = performance.now();
        
        do {
          if (index >= longTask.length) {
            resolve(chunks);
            return;
          }
          
          chunks.push(longTask[index]);
          index++;
        } while (performance.now() - startTime < chunkSize);
        
        // 次のチャンクを非同期で処理
        setTimeout(processChunk, 0);
      };
      
      processChunk();
    });
  }
}

CLS最適化戦略

javascript
// レイアウトシフトの予防と監視
class CLSOptimizer {
  static reserveSpace(element, dimensions) {
    // 要素のスペース予約
    element.style.minHeight = dimensions.height + 'px';
    element.style.minWidth = dimensions.width + 'px';
  }
  
  static implementSkeletonLoading(container, skeletonTemplate) {
    // スケルトンローディングの実装
    const skeleton = document.createElement('div');
    skeleton.innerHTML = skeletonTemplate;
    skeleton.classList.add('skeleton-loading');
    
    container.appendChild(skeleton);
    
    return {
      remove: () => {
        if (skeleton.parentNode) {
          skeleton.parentNode.removeChild(skeleton);
        }
      }
    };
  }
  
  static trackLayoutShifts() {
    const shifts = [];
    
    const observer = new PerformanceObserver((list) => {
      list.getEntries().forEach((entry) => {
        if (!entry.hadRecentInput) {
          shifts.push({
            value: entry.value,
            startTime: entry.startTime,
            sources: entry.sources.map(source => ({
              node: source.node.tagName,
              previousRect: source.previousRect,
              currentRect: source.currentRect
            }))
          });
          
          // 閾値を超えた場合の警告
          if (entry.value > 0.1) {
            console.warn('大きなレイアウトシフトが検出されました:', entry);
          }
        }
      });
    });
    
    observer.observe({ type: 'layout-shift', buffered: true });
    
    return shifts;
  }
}

アラートとダッシュボードの設定

Core Web Vitalsの継続的な監視のためのアラート設定とダッシュボード構築を行います。

カスタムダッシュボードの作成

New RelicのNRQLを使用して、Core Web Vitalsの傾向と分析を可視化します。

sql
-- LCPの時系列分析
SELECT average(numeric(custom.lcp_value)) 
FROM PageAction 
WHERE actionName = 'core_web_vitals_lcp' 
TIMESERIES 1 hour 
SINCE 7 days ago

-- デバイス別パフォーマンス比較
SELECT average(numeric(custom.lcp_value)) as 'LCP',
       average(numeric(custom.inp_value)) as 'INP',
       average(numeric(custom.cls_value)) as 'CLS'
FROM PageAction 
WHERE actionName LIKE 'core_web_vitals_%'
FACET custom.deviceType
SINCE 1 day ago

-- パフォーマンス評価の分布
SELECT count(*) 
FROM PageAction 
WHERE actionName LIKE 'core_web_vitals_%'
FACET custom.grade
SINCE 1 day ago

アラート条件の設定

重要なパフォーマンス閾値に基づいてアラートを設定します。

sql
-- LCP悪化アラート
SELECT average(numeric(custom.lcp_value)) 
FROM PageAction 
WHERE actionName = 'core_web_vitals_lcp'
-- 閾値: 4000ms(Poor状態)

-- INP悪化アラート  
SELECT average(numeric(custom.inp_value))
FROM PageAction 
WHERE actionName = 'core_web_vitals_inp'
-- 閾値: 500ms(Poor状態)

-- CLS悪化アラート
SELECT average(numeric(custom.cls_value))
FROM PageAction 
WHERE actionName = 'core_web_vitals_cls'
-- 閾値: 0.25(Poor状態)

まとめ

Core Web VitalsのNew Relic監視により、ユーザーエクスペリエンスの定量的な評価と継続的な改善が可能になります。自動監視の設定から詳細な最適化戦略の実装まで、包括的なアプローチによりWebサイトのパフォーマンスとSEO評価の向上を実現できます。

次のステップでは、Browser Agentの設定リファレンスについて詳しく解説します。高度なカスタマイズオプションと設定パラメータの完全ガイドを学んでいきましょう。


関連記事: Browser Agent設定リファレンス関連記事: Browser Agent概要