个人站不需要一开始就接入完整 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。先确认部署平台能够按 type、status 和时间范围查询,日志保留七到三十天通常已经够用。若后续迁移平台,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 自动覆盖。
阈值与告警的取舍
小项目最容易出现的问题是阈值太敏感:一次网络抖动就发通知,几天后所有告警都会被忽略。更合适的做法是同时设置持续窗口和最小样本量。
| 指标 | 建议起点 | 处理方式 |
|---|---|---|
| 服务端 5xx | 5 分钟内至少 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。先让轻量方案稳定运行,通常比一次性堆齐所有工具更有效。
评论