🐥

Server-Timing ヘッダの情報をフロントエンド解析に使う

2024/11/08に公開

Server-Timing というヘッダがあります。サーバーからブラウザへのレスポンスヘッダとして、リクエスト内で発生した指標を送ることができます。

Server-Timing: miss;desc="Cache Miss", hit;desc="Cache Hit", db;dur=53.2, app;dur=47.2

https://developer.mozilla.org/en-US/docs/Web/API/PerformanceServerTiming

何に使うかというと、ブラウザの DevTools や JS API からこの値にアクセスすることができます。

https://support.speedcurve.com/docs/using-server-timing

(現代のブラウザサンドボックスやJSではサーバーから送られたすべてのヘッダを覗き見れるわけではありませんが、これは見える値の一つです)

サーバーから Server-Timing を返す方法

実験用の hono + deno で雑なサーバーをでっち上げます。

import { Hono } from "npm:hono@4.6.9";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

type Post = {
  title: string;
  content: string;
};

type User = {
  id: number;
  name: string;
};

const renderHtml = (user: User, post: Post) => `
<html>
<head>
  <title>Hello</title>
  <meta charset="utf-8">
</head>
<body>
  <h1>Hello</h1>
  <div>
    <h2>${post.title} by ${user.name}</h2>
    <p>${post.content}</p>
</body>
</html>
`;

async function getPost(_userId: number): Promise<Post> {
  const rand = Math.floor(Math.random() * 300);
  await wait(rand);
  return {
    title: "Main Content",
    content: `This is the main content. It took ${rand}ms to load.`,
  };
}

async function getUser(): Promise<User> {
  const rand = Math.floor(Math.random() * 200);
  await wait(rand);
  return {
    id: 1,
    name: "John Doe",
  };
}

// server app
const app = new Hono();
app.get("/", async (c) => {
  const userStarted = performance.now();
  const user = await getUser();
  const userDuration = performance.now() - userStarted;

  const started = performance.now();
  const post = await getPost(user.id);
  const duration = performance.now() - started;

  const html = renderHtml(user, post);

  return c.html(html, 200, {
    "Server-Timing": `post;dur=${duration},user;dur=${userDuration}`,
  });
});

Deno.serve(app.fetch);

deno run -A server.ts で起動して、 http://localhost:8000 にアクセスします。

やってること

  • getUser で適当に遅延を入れて解決
  • gutPost は getUser に依存して、適当に遅延を入れて解決
  • この2つに遅延を name;dur=time の形式で Server-Timing ヘッダに入れて返す

Chrome DevTools でこの値を確認する

これは GET http://localhost:8000Content-Type: text/html のリクエストヘッダとして返却されています。

Chrome 131だと、 Chrome DevTools > Network > 対象リクエスト選択 > Timing で一番下までスクロールするとそのタイミングの内訳が見れます。

JS の PerformanceObserver から Server-Timing にアクセスする

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    entry.serverTiming.forEach((serverEntry) => {
      console.log(
          JSON.stringify(serverEntry, null, 2)
      );
    });
  });
});
["navigation", "resource"].forEach((type) =>
  observer.observe({ type, buffered: true }),
);

/*
{
  "name": "post",
  "duration": 217.5807999999961,
  "description": ""
}
VM387:4 {
  "name": "user",
  "duration": 98.9096000000136,
  "description": ""
}
*/

このAPIは基本的に Same-Origin のみ実行可能ですが、 Timing-Allow-Origin これを実行できるオリジンを追加することもできます

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin

運用方法

各種RUMやAPMではサーバー側では、分散トレーシング等で同等のデータを計測しているはずです。

https://support.speedcurve.com/docs/using-server-timing
https://docs.datadoghq.com/ja/tracing/metrics/

これをクライアントのリクエストヘッダとして露出していいか?が争点になります。

フロントエンドのチューニング目線視点だと、ブラウザからはブラックボックス化されてる初期リクエストのTTFBやAPIレスポンスを解析する手がかりになります。

攻撃者視点だと、遅いメトリクスを知ることで効率よくDDOSを作ることが可能になるかもしれません。また、仮にシステムコールのようなプリミティブなデータを露出してしまうと、システムコールする経路がある事自体が知られてしまいます。

個人的な意見としては、計測作業が負荷にならない範囲でアプリケーションの開発者の視点で追加してしまっていい気がします。似たようなものとしてE2Eテスト用の data-testid をHTMLでそのまま露出しているサイトも多いです(例: facebook)。

data-testid の場合は外部スクレイピングが捗るより、本番環境で E2E を実行出来るというアドの方が大きく、Server-Timing も同種のデータだと考えます。

これでもまだセキュリティ懸念がある場合、staging 環境のみ露出、特定のIDのユーザー(開発者)のみ露出、特定のトークンを持つ人だけ露出、といった段階的な制御ができれば十分な気がします。開発者用データですからね。

というわけで、外形監視のみでもこれがあればフロントエンドのパフォーマンス計測ができるよ、という話でした。

Discussion