SSR 采集
在服务端渲染(SSR)阶段采集错误与性能,注入 HTML,由客户端注水后统一上报。
SSR 阶段发生在用户浏览器之前,客户端 SDK 看不到。lens 用「方案 D」补上这段:服务端只采集 + 注入容器、绝不直接发请求;上报统一交客户端走标准管线。
接线分两侧:客户端(所有环境一样)打开开关;服务端(按框架不同)采集并注入容器。
SSR 接入请走打包器 ESM
SSR 采集的服务端这一半依赖 @tripo3d/lens/server(仅 Node 的 ESM 子路径),必须经打包器(Vite / webpack / Nuxt)走 ESM 接入才能完成服务端采集与容器注入,详见 安装文档。
客户端:打开 captureSsr(所有环境通用)
import { TripoLens, ErrorPlugin, PerformancePlugin } from '@tripo3d/lens';
const trace = new TripoLens({
app: 'shop',
configUrl: '...',
endpoint: '...',
plugins: [
new ErrorPlugin({ captureSsr: true }), // SSR 服务端错误(默认关,需打开)
new PerformancePlugin({ captureSsr: true }), // SSR 渲染性能(默认关,需打开)
],
});
await trace.init(); // 注水后一次性读取容器并上报客户端 init() 时会一次性读取 window.__TRIPO_SSR_PERF__ / __TRIPO_SSR_ERR__ 两个容器(读后清空),展开成标准 performance / ssr 错误事件上报。容器不存在就自然 no-op——所以即使服务端还没接,客户端打开也安全。
服务端:按环境接线
服务端原语来自 @tripo3d/lens/server(仅 Node、禁止浏览器 import)。下面按你的运行环境选一种。
环境 1:Nuxt 3 / Nitro(推荐用适配器)
Nuxt 项目直接用 @tripo3d/lens/nuxt,它会自动完成下面所有服务端接线,你只需在客户端打开 captureSsr。
手动接线(不便用适配器时参考)
Nitro 是 hook 通知模型、不暴露「渲染 wrap 点」,并发出站请求的归属必须靠宿主请求级 ALS + useEvent() 取回,而不是插件自己包裹。关键四步:
// server plugin(Nitro)
import {
createFetchTimingWrapper,
newRenderId,
serializeSsrContainers,
SsrErrorBuffer,
SsrPerfCollector,
} from '@tripo3d/lens/server';
import { defineNitroPlugin, useEvent } from 'nitropack/runtime';
const PERF_KEY = '__tripoPerf';
const ERR_KEY = '__tripoErr';
// 取当前请求(useEvent,依赖 Nitro asyncContext)event.context 上的 perf collector
function currentPerf(): SsrPerfCollector | undefined {
try {
const event = useEvent() as { context?: Record<string, unknown> } | undefined;
return event?.context?.[PERF_KEY] as SsrPerfCollector | undefined;
} catch {
return undefined;
}
}
export default defineNitroPlugin((nitroApp) => {
// ① patch 底层 globalThis.fetch:每个出站请求发起时经 useEvent() 解析并归属到当前请求的 collector。
// patch 原生 fetch(而非替换全局 $fetch)才能兜住业务自建的独立 ofetch 实例——
// 如 `$fetch.create()` 派生实例不继承被替换的全局 $fetch 拦截器,但底层最终仍走 globalThis.fetch。
// 带幂等标记:HMR / 重复注册只 patch 一次。
const g = globalThis as unknown as { __tripoFetchPatched?: boolean; fetch: typeof fetch };
if (!g.__tripoFetchPatched && typeof g.fetch === 'function') {
g.fetch = createFetchTimingWrapper(currentPerf)(g.fetch);
g.__tripoFetchPatched = true;
}
// ② 每个请求建采集器,挂到 h3 event.context
nitroApp.hooks.hook('request', (event) => {
event.context[PERF_KEY] = new SsrPerfCollector(newRenderId());
event.context[ERR_KEY] = new SsrErrorBuffer();
});
// ③ 服务端错误进 buffer
nitroApp.hooks.hook('error', (error, ctx) => {
(ctx?.event?.context?.[ERR_KEY] as SsrErrorBuffer | undefined)?.record(error);
});
// ④ 渲染产出 HTML 时注入两个容器
nitroApp.hooks.hook('render:html', (html, { event }) => {
const perf = event.context[PERF_KEY] as SsrPerfCollector | undefined;
const err = event.context[ERR_KEY] as SsrErrorBuffer | undefined;
const snippet = serializeSsrContainers({
...(perf ? { perf: perf.snapshot() } : {}),
...(err && err.size > 0 ? { errors: err.snapshot() } : {}),
});
if (snippet) html.bodyAppend.push(snippet);
});
});必须开启 asyncContext
被 patch 的 globalThis.fetch 在请求发起时用 useEvent() 取当前请求,依赖 Nitro 在请求最外层用 ALS 包裹。需在 nuxt.config.ts 开启:
export default defineNuxtConfig({
nitro: { experimental: { asyncContext: true } },
});不开的话渲染期 useEvent() 取不到 event,出站请求 waterfall 会缺失(但渲染耗时与错误仍可采集)。
环境 2:通用 Node SSR(Express / Koa / 自定义 renderToString)
当你自己控制渲染调用、且有 per-request 载体时,用实例直挂(最简单、不依赖 ALS):
import {
SsrPerfCollector,
SsrErrorBuffer,
newRenderId,
createFetchTimingInterceptor,
serializeSsrContainers,
} from '@tripo3d/lens/server';
app.get('*', async (req, res) => {
const perf = new SsrPerfCollector(newRenderId());
const errors = new SsrErrorBuffer();
// 为「本次请求」造一个带计时的 $fetch(ofetch 形状拦截器,固定归属到本 collector)
const interceptor = createFetchTimingInterceptor(perf);
const $fetch = ofetch.create({
onRequest: interceptor.onRequest,
onResponse: interceptor.onResponse,
onRequestError: interceptor.onRequestError,
});
try {
perf.markPhase('render:start');
const appHtml = await renderToString(createApp({ $fetch })); // 用上面的 $fetch 取数
perf.markPhase('render:end');
const snippet = serializeSsrContainers({ perf: perf.snapshot(), errors: errors.snapshot() });
res.send(template.replace('</head>', `${snippet}</head>`));
} catch (err) {
errors.record(err, 500); // 渲染异常进 buffer,仍随下一次可注入或记录
res.status(500).send('Internal Error');
}
});要点:每个请求一个 SsrPerfCollector / SsrErrorBuffer 实例;出站取数用经 createFetchTimingInterceptor(perf) 包过的 $fetch,请求 waterfall 才会被记到本次渲染。
环境 3:可 wrap 渲染函数、但无 per-request 载体
如果框架没有 event.context 这类载体,但你能把渲染逻辑包成一个函数,用 ALS 兜底:run() 建 collector 并在其上下文内执行渲染,出站 $fetch 用 ambient 拦截器自动归属:
import {
run,
currentCollector,
newRenderId,
createAmbientFetchTimingInterceptor,
serializeSsrContainers,
} from '@tripo3d/lens/server';
// 全局只 wrap 一次共享 $fetch:归属到「当前 ALS 上下文」的 collector
const interceptor = createAmbientFetchTimingInterceptor();
const $fetch = ofetch.create({
onRequest: interceptor.onRequest,
onResponse: interceptor.onResponse,
onRequestError: interceptor.onRequestError,
});
const html = run(newRenderId(), async () => {
const appHtml = await renderToString(app); // 期间 $fetch 自动归属到本次 run 的 collector
const snippet = serializeSsrContainers({ perf: currentCollector()!.snapshot(), errors: [] });
return appHtml.replace('</head>', `${snippet}</head>`);
});服务端 API 速览
| 导出 | 类型 / 说明 |
|---|---|
SsrPerfCollector | new SsrPerfCollector(renderId, { maxRequests?=100, keepSlowest? });.markPhase(name) / .snapshot() / .currentRenderId() |
SsrErrorBuffer | new SsrErrorBuffer({ maxErrors?=20 });.record(err, statusCode?) / .snapshot() / .size |
newRenderId() | 生成贯穿服务端/客户端的渲染 id(crypto.randomUUID) |
createFetchTimingInterceptor(collector) | 固定 collector 的 ofetch 形拦截器(实例直挂用) |
createAmbientFetchTimingInterceptor() | 记到 ALS 当前 collector(配合 run()) |
createResolvedFetchTimingInterceptor(resolve) | 每次动态解析 collector(ofetch 形拦截器的动态归属版) |
createFetchTimingWrapper(resolveCollector, options?) | 原生 fetch 形状的计时包装器:(origFetch) => wrappedFetch,发起时经 resolveCollector() 解析归属,无 collector 则透传不采;options.sanitizeUrl 默认 stripQuery。Nuxt 适配器即用它 patch globalThis.fetch |
stripQuery(url) | URL 脱敏(去 query/hash),fetch 包装器的默认 sanitizeUrl |
run(renderId, fn) / enterCollector / currentCollector / markPhase / recordRequest | ALS 兜底层原语 |
serializeSsrContainers({ perf?, errors? }) | 把快照序列化成可注入 HTML 的 <script>(含 XSS 转义) |
三种
*Interceptor都是 ofetch 形状(onRequest/onResponse/onRequestError),插到 ofetch / Nuxt$fetch的拦截器即可;createFetchTimingWrapper则是原生 fetch 形状,用于 patchglobalThis.fetch,兜住不走全局$fetch拦截器的派生 ofetch 实例。出站请求计时是可选增强——不接它,渲染耗时与错误仍照常采集。
实现原理(方案 D)
服务端(Node,绝不直发)
├─ SsrPerfCollector 采集渲染各阶段耗时 + 出站请求计时(per-request 一个实例)
├─ SsrErrorBuffer 缓冲渲染期错误(≤20 条、栈 ≤10 帧、URL 去 query)
└─ serializeSsrContainers → 注入初始 HTML:
window.__TRIPO_SSR_PERF__ = {...}
window.__TRIPO_SSR_ERR__ = [...]
▼ HTML 传输
客户端(浏览器)
└─ init() 一次性读容器(读后清空,防重复)
├─ PerformancePlugin: expandSsrPerf → ssr.render(1 条)+ ssr.request(每出站 1 条)
└─ ErrorPlugin: expandSsrErrors → 逐条 ssr 子类错误
▼ 走标准 L4 → L5,与客户端事件同路上报(性能按 control.performance 采样、ssr 错误 100%)ssr.render 携带 renderId,并贯穿到客户端 hydration / navigation 事件——后端按 renderId join,用 render[0, totalMs] + 每条 request[startOffset, +duration] 重建 SSR 瀑布。
三个入口分工:@tripo3d/lens/server(Node:采集 + 注入)、@tripo3d/lens/shared(同构纯函数 expandSsrPerf/expandSsrErrors)、主入口(浏览器:captureSsr 读容器、上报)。
注意事项
- 并发归属是难点:SSR 渲染期会并发多个出站请求,必须各自找回归属的 collector,否则跨请求串账。Nitro 这类「只给 hook、不给渲染 wrap 点」的框架,必须靠宿主请求级
asyncContext+useEvent(),不能靠插件自己enterWith(已被运行时证伪)。能直接拿到 collector 的环境(环境 2)用实例直挂最稳。 /server只能在 Node import:依赖node:async_hooks/node:crypto,带进浏览器会爆错。- 容器必须在初始 HTML 里:要在客户端 bundle 执行前就绪,注入到
<head>或bodyAppend。 - 一次性读取:客户端
init()读一次即清空,不重复展开。 - 两个
captureSsr都默认关:ErrorPlugin与PerformancePlugin的 SSR 采集都是 opt-in,需手动打开——都要服务端先注入对应容器才有意义,容器不存在时即便打开也自然 no-op。 - 限额:每次渲染错误 ≤20 条、出站请求 ≤100 条(超出保留最慢 N 条并标
truncated)。