个人站不需要一开始就接入完整 APM,但应该能回答三个问题:哪里出错了、用户实际体验如何、这次发布是否让站点变重。本文用 Astro、结构化标准输出和少量脚本,搭建一套可以直接复制的基础方案。

先定义最小可观测闭环

可观测性不是简单地“多打日志”,而是让异常能够沿着采集、聚合、判断和处理形成闭环。对个人站或小项目,我通常只保留四类信号:

信号要回答的问题存储位置
请求日志哪个路径慢或返回了错误部署平台标准输出
前端错误浏览器发生了什么异常服务端采集端点及日志
Web Vitals真实用户体验是否退化服务端采集端点及日志
构建体积本次提交是否引入明显膨胀Git 仓库基线与 CI 产物

这里不做调用链追踪、会话回放和完整用户画像。一方面,小站流量通常不足以支撑复杂分析;另一方面,这些能力会增加客户端体积、费用和隐私治理成本。

下面示例使用 Astro 的服务端输出模式,让日志和遥测端点运行在同一个应用中:

npm install @astrojs/node web-vitals
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" })
});

如果站点必须保持纯静态部署,也可以把后文的 /api/telemetry 原样迁移到 Cloudflare Workers、Vercel Functions 等函数环境,浏览器端只需更换上报地址。

用结构化日志覆盖服务端请求

结构化日志的重点不是字段越多越好,而是格式稳定、能够检索。创建 src/middleware.ts,为每次请求记录路径、状态码和耗时:

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  const startedAt = performance.now();
  const requestId = context.request.headers.get("x-request-id")
    ?? crypto.randomUUID();
  let status = 500;
  let outcome = "error";

  try {
    const response = await next();
    status = response.status;
    outcome = status >= 500 ? "error" : "ok";
    return response;
  } finally {
    const url = new URL(context.request.url);

    console.log(JSON.stringify({
      type: "http_request",
      timestamp: new Date().toISOString(),
      requestId,
      method: context.request.method,
      path: url.pathname,
      status,
      outcome,
      durationMs: Math.round(performance.now() - startedAt)
    }));
  }
});

日志只写入标准输出,由容器或托管平台负责保存。不要记录查询字符串、Cookie、Authorization、表单内容和完整 IP;这些字段对个人站的排障价值有限,却会明显增加敏感信息风险。

请求量较小时没必要提前建设 Elasticsearch。先确认部署平台能够按 typestatus 和时间范围查询,日志保留七到三十天通常已经够用。若后续迁移平台,JSON 行日志也比平台专有 SDK 更容易搬迁。

采集前端错误与 Web Vitals

先增加一个同源接收端点。它限制请求体大小、事件类型和来源,只输出经过筛选的数据:

// src/pages/api/telemetry.ts
import type { APIRoute } from "astro";

export const prerender = false;

const allowedTypes = new Set(["frontend_error", "web_vital"]);

export const POST: APIRoute = async ({ request }) => {
  const requestUrl = new URL(request.url);
  const origin = request.headers.get("origin");

  if (origin && origin !== requestUrl.origin) {
    return new Response("Forbidden", { status: 403 });
  }

  const text = await request.text();
  if (new TextEncoder().encode(text).byteLength > 16_384) {
    return new Response("Payload too large", { status: 413 });
  }

  try {
    const data = JSON.parse(text) as Record<string, unknown>;
    if (typeof data.type !== "string" || !allowedTypes.has(data.type)) {
      return new Response("Invalid event", { status: 400 });
    }

    console.log(JSON.stringify({
      ...data,
      receivedAt: new Date().toISOString()
    }));

    return new Response(null, { status: 204 });
  } catch {
    return new Response("Invalid JSON", { status: 400 });
  }
};

然后创建客户端组件 src/components/Telemetry.astro,并在全站 Layout 中引用一次:

<script>
  import { onCLS, onINP, onLCP, type Metric } from "web-vitals";

  const endpoint = "/api/telemetry";

  function send(payload: Record<string, unknown>) {
    const body = JSON.stringify({
      ...payload,
      page: location.pathname,
      timestamp: new Date().toISOString()
    });

    if (navigator.sendBeacon(endpoint, new Blob([body], {
      type: "application/json"
    }))) return;

    void fetch(endpoint, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body,
      keepalive: true
    });
  }

  function sendVital(metric: Metric) {
    send({
      type: "web_vital",
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      id: metric.id,
      navigationType: metric.navigationType
    });
  }

  window.addEventListener("error", (event) => {
    send({
      type: "frontend_error",
      kind: "error",
      message: event.message.slice(0, 500),
      file: event.filename ? new URL(event.filename, location.href).pathname : "",
      line: event.lineno,
      column: event.colno
    });
  });

  window.addEventListener("unhandledrejection", (event) => {
    const reason = event.reason instanceof Error
      ? event.reason.message
      : String(event.reason);

    send({
      type: "frontend_error",
      kind: "unhandledrejection",
      message: reason.slice(0, 500)
    });
  });

  onCLS(sendVital);
  onINP(sendVital);
  onLCP(sendVital);
</script>

示例没有上传错误堆栈,因为堆栈可能带出路径、参数或业务数据。排查能力不足时,可以仅在生产构建中增加截断后的堆栈,并同步管理 source map 的访问权限。低流量站点初期可以全量采集;事件明显增多后,再对 Web Vitals 做固定比例采样,但错误事件应优先保留。

建立构建体积基线并接入 CI

真实用户指标发现的是已发生的退化,构建预算则负责在发布前拦截问题。下面脚本统计 Astro 生成的 JS 和 CSS,并同时检查绝对上限与相对基线。

// scripts/check-assets.mjs
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { gzipSync } from "node:zlib";

const roots = ["dist/client/_astro", "dist/_astro"];
const root = roots.find(existsSync);
if (!root) throw new Error("未找到 Astro 构建产物,请先运行 astro build");

function walk(dir) {
  return readdirSync(dir).flatMap((name) => {
    const file = join(dir, name);
    return statSync(file).isDirectory() ? walk(file) : [file];
  });
}

const files = walk(root)
  .filter((file) => /\.(js|css)$/.test(file))
  .map((file) => {
    const content = readFileSync(file);
    return {
      file: relative(root, file),
      rawBytes: content.byteLength,
      gzipBytes: gzipSync(content).byteLength
    };
  });

const report = {
  generatedAt: new Date().toISOString(),
  totalGzipBytes: files.reduce((sum, item) => sum + item.gzipBytes, 0),
  files
};

mkdirSync("artifacts", { recursive: true });
writeFileSync("artifacts/build-metrics.json", JSON.stringify(report, null, 2));

const baselineFile = "performance-baseline.json";
if (process.argv.includes("--update")) {
  writeFileSync(baselineFile, JSON.stringify(report, null, 2));
  console.log(`已更新基线:${report.totalGzipBytes} gzip bytes`);
  process.exit(0);
}

if (!existsSync(baselineFile)) {
  throw new Error("缺少基线,请先使用 --update 生成并提交 performance-baseline.json");
}

const baseline = JSON.parse(readFileSync(baselineFile, "utf8"));
const maxTotal = 307_200;
const maxFile = 153_600;
const maxRegression = baseline.totalGzipBytes * 1.1;
const oversized = files.filter((item) => item.gzipBytes > maxFile);

if (report.totalGzipBytes > maxTotal ||
    report.totalGzipBytes > maxRegression ||
    oversized.length > 0) {
  console.error(JSON.stringify({ report, baseline, oversized }, null, 2));
  process.exit(1);
}

console.log(`体积检查通过:${report.totalGzipBytes} gzip bytes`);

第一次运行 npm run build && node scripts/check-assets.mjs --update,检查生成结果后,将基线文件提交到 Git。CI 中执行:

# .github/workflows/performance.yml
name: Performance budget
on: [pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build
      - run: node scripts/check-assets.mjs

示例中的 300 KiB 总量、150 KiB 单文件和 10% 回归都是起始预算,不是适用于所有站点的标准。应先用当前稳定版本生成基线,再根据字体、图片处理方式和交互复杂度调整。基线只能在明确接受体积变化的提交中更新,不能让 CI 自动覆盖。

阈值与告警的取舍

小项目最容易出现的问题是阈值太敏感:一次网络抖动就发通知,几天后所有告警都会被忽略。更合适的做法是同时设置持续窗口和最小样本量。

指标建议起点处理方式
服务端 5xx5 分钟内至少 3 次立即通知并检查发布记录
同类前端错误10 分钟内至少 5 次按消息、文件和页面聚合
LCP样本不少于 50 时,P75 超过 2500ms观察一周趋势
INP样本不少于 50 时,P75 超过 200ms检查长任务和第三方脚本
CLS样本不少于 50 时,P75 超过 0.1检查图片尺寸和动态内容
构建体积相对基线上升 10% 或超过绝对预算阻止合并,人工确认

Web Vitals 的三个数值采用 Core Web Vitals 的“良好”边界,但小站样本不足时不应据此频繁告警。可以把实时通知留给服务端错误和错误突增,把性能指标改成每周检查。采集端点还应在 CDN 或平台层增加请求频率限制,防止它成为公开的日志写入接口。

总结

一套适合 Astro 小站的基础可观测性,不需要先引入复杂平台,关键是建立可以持续执行的约束:

  • 服务端使用 JSON 行日志,稳定记录路径、状态码、耗时和请求标识;
  • 浏览器只上报必要的错误字段,不采集查询参数、Cookie 和用户输入;
  • 使用 web-vitals 记录 LCP、INP、CLS,并以 P75 和最小样本量判断趋势;
  • 将 JS、CSS 的 gzip 体积写入基线,在拉取请求中检查绝对预算和相对回归;
  • 实时告警只覆盖需要立即处理的错误,低流量性能数据以周期复盘为主。

等到这些数据确实无法回答问题,再考虑接入托管错误平台、日志查询服务或完整 APM。先让轻量方案稳定运行,通常比一次性堆齐所有工具更有效。