Skip to content

SSR 采集 ​

在服务端渲染(SSR)阶段采集错误与性能,注入 HTML,由客户端注水后统一上报。

SSR 阶段发生在用户浏览器之前,客户端 SDK 看不到。lens 用「方案 D」补上这段:服务端只采集 + 注入容器、绝不直接发请求;上报统一交客户端走标准管线。

接线分两侧:客户端(所有环境一样)打开开关;服务端(按框架不同)采集并注入容器。

SSR 接入请走打包器 ESM

SSR 采集的服务端这一半依赖 @tripo3d/lens/server(仅 Node 的 ESM 子路径),必须经打包器(Vite / webpack / Nuxt)走 ESM 接入才能完成服务端采集与容器注入,详见 安装文档。

客户端:打开 captureSsr(所有环境通用) ​

ts
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() 取回,而不是插件自己包裹。关键四步:

ts
// 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 开启:

ts
export default defineNuxtConfig({
  nitro: { experimental: { asyncContext: true } },
});

不开的话渲染期 useEvent() 取不到 event,出站请求 waterfall 会缺失(但渲染耗时与错误仍可采集)。

环境 2:通用 Node SSR(Express / Koa / 自定义 renderToString) ​

当你自己控制渲染调用、且有 per-request 载体时,用实例直挂(最简单、不依赖 ALS):

ts
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 拦截器自动归属:

ts
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 速览 ​

导出类型 / 说明
SsrPerfCollectornew SsrPerfCollector(renderId, { maxRequests?=100, keepSlowest? });.markPhase(name) / .snapshot() / .currentRenderId()
SsrErrorBuffernew 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 / recordRequestALS 兜底层原语
serializeSsrContainers({ perf?, errors? })把快照序列化成可注入 HTML 的 <script>(含 XSS 转义)

三种 *Interceptor 都是 ofetch 形状(onRequest/onResponse/onRequestError),插到 ofetch / Nuxt $fetch 的拦截器即可;createFetchTimingWrapper 则是原生 fetch 形状,用于 patch globalThis.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)。

下一步 ​

基于 MIT 协议发布(内部使用)