NewLife/Stardust

README.md
# Stardust JavaScript SDK 星尘监控(Stardust APM)JavaScript/Node.js SDK,零运行时依赖,支持 Node.js 14+ 与现代浏览器。 - **Node.js**:应用性能心跳、埋点聚合、GZip 上报、令牌自动续期、进程退出自动刷新;默认自动埋点 HTTP 出入站、`pg`/`mysql2`、`redis`/`ioredis`。 - **浏览器**:单文件 UMD 或 `loader.js` + `data-stardust-*` 一行接入、稳定 ClientId、页面加载/路由/AJAX/JS 错误自动埋点、`sendBeacon` 离页上报;fetch/XHR 自动注入 `traceparent`,支持后端注入 `initialTraceparent` 关联 `page.load`。 所有接口均为 HTTP POST,成功返回 `{ code: 0, data: {...} }`,失败返回 `{ code, message }`(HTTP 200)。令牌通过 `?Token=...` 查询参数传递。 --- ## 安装 ```bash # 发布到 npm 后 npm install @newlifex/stardust # 或直接复制源码 cp -r SDK/JavaScript/src /your-project/node_modules/@newlifex/stardust ``` 浏览器无需构建步骤,直接引入 `src/browser.js`: ```html <script src="node_modules/@newlifex/stardust/src/browser.js"></script> <!-- 或 CDN:https://cdn.jsdelivr.net/npm/@newlifex/stardust/src/browser.js --> ``` --- ## 浏览器一键接入 最简方式:只引一个 `loader.js`,配置全部放在 `data-*` 属性上(推荐公网应用使用配置端点,避免前端出现 secret): ```html <script src="https://cdn.example.com/stardust/loader.js" data-stardust-app="MyWebApp" data-stardust-config="/stardust/browser-config" defer></script> ``` `loader.js` 会动态加载同目录的 `browser.js`,从 `/stardust/browser-config` 读取 `server`、`token`、`version`、`sampleRate` 等配置,然后自动启动页面/路由/fetch/XHR/JS 错误埋点。后端可在 HTML 中注入 `<meta name="traceparent" content="...">`(或 `stardust-traceparent`),把 `page.load` 关联到服务端链路。 静态站点也可以不用配置端点: ```html <script src="https://cdn.example.com/stardust/loader.js" data-stardust-app="MyWebApp" data-stardust-server="https://star.example.com:6600" data-stardust-token-value="eyJ..." data-stardust-release="1.2.3" defer></script> ``` `data-stardust-token` 也可以指向 Token 端点(推荐有后端时使用)。`data-stardust-token-value` 适合静态页面,配合 `data-stardust-expire="300"` 指定有效期;Token 应短有效期且仅具备上报权限。跨域链路传播需显式配置 `data-stardust-trace-targets="https://api.example.com,..."`,默认仅同源注入 `traceparent`。 npm 用户可以使用一行 `init`: ```js import { init } from '@newlifex/stardust/browser'; const tracer = init({ appId: 'MyWebApp', configUrl: '/stardust/browser-config', tokenUrl: '/stardust/browser-token', autoInstrument: true, }); // 可选快捷 API Stardust.captureException(new Error('checkout failed'), { tag: 'order=123' }); await Stardust.flush(); ``` 公开 API: | API | 说明 | |-----|------| | `Stardust.init(options)` | 创建并启动追踪器(loader 自动调用) | | `Stardust.captureException(err, { tag })` | 手动上报异常,生成 `js.error` Span | | `Stardust.flush()` | 立即上报当前聚合数据 | | `Stardust.createTracer(options)` / `tracer.start()` | 兼容旧接入,手动控制启动 | | `Stardust.tracer` | 初始化后的追踪器实例(高级用法) | 需要手动控制时,仍可直接引入 `browser.js`: ```html <script src="../src/browser.js"></script> <script> const tracer = Stardust.createTracer({ server: 'http://star.example.com:6600', appId: 'MyWebApp', appName: '我的网站', version: '1.1.0', // 公网应用必须使用 tokenProvider,不要在前端写 secret tokenProvider: async () => { const resp = await fetch('/api/stardust-token', { credentials: 'same-origin' }); const data = await resp.json(); return { token: data.token, expire: data.expire }; }, period: 60, autoInstrument: true, debug: false, }); tracer.start(); // 手动埋点 const span = tracer.newSpan('submit-order'); span.setTag('orderId=123'); try { await submitOrder(); } catch (err) { span.setError(err); throw err; } finally { span.finish(); } </script> ``` 页面卸载或进入后台时,SDK 会调用: ```js navigator.sendBeacon(`${server}/Trace/Report?Token=...`, new Blob([json], { type: 'application/json' })); ``` `sendBeacon` 不可用时自动退回 `fetch(..., { keepalive: true })`。 ### ClientId 与令牌缓存 | 数据 | 存储位置 | 说明 | |------|----------|------| | ClientId | `localStorage["stardust.clientId.<appId>"]` | `browser@<32 位十六进制>`,同一应用稳定复用 | | Token | `sessionStorage["stardust.token.<appId>"]` | JSON `{ token, expire }`,`expire` 为毫秒时间戳 | 存储不可用(隐私模式、SecurityError)时回退到内存,不影响页面运行。 ### tokenProvider 推荐模式(公网必备) `secret` 会出现在前端源码中,**任何公网可访问的页面都不得使用 secret 模式**。推荐由后端用 AppId/Secret 登录后,把短期令牌下发给前端: ```js const tracer = Stardust.createTracer({ server: 'http://star.example.com:6600', appId: 'MyWebApp', tokenProvider: async () => { const resp = await fetch('/api/stardust-token', { credentials: 'same-origin' }); const data = await resp.json(); return { token: data.token, expire: data.expire }; // expire 支持相对秒/绝对秒/绝对毫秒 }, }); ``` - 返回字符串或 `{ token, expire }` 均可,支持 `async`。 - 令牌将在 5 分钟内过期时主动刷新;响应 `code: 401` 或 HTTP 401 时刷新并重试一次。 - `secret` 模式仅建议用于内网、Electron 或受控环境。 --- ## Node.js 快速开始 一行接入(自动连接 + 自动埋点): ```js const { init } = require('@newlifex/stardust'); const tracer = init({ server: 'http://star.example.com:6600', appId: 'MyApp', secret: 'MySecret', version: '1.2.3', }); ``` 需要手动控制启动/停止时: ```js const { StardustTracer } = require('@newlifex/stardust'); const tracer = new StardustTracer({ server: 'http://star.example.com:6600', appId: 'MyApp', appName: '订单服务', secret: 'MySecret', version: '1.1.0', period: 60, heartbeatPeriod: 30, enableMeter: true, autoFlushOnExit: true, debug: false, }); async function main() { await tracer.start(); const span = tracer.newSpan('业务操作'); span.setTag('userId=123'); try { await doSomething(); } catch (err) { span.setError(err); throw err; } finally { span.finish(); } await tracer.stop(); // 停止定时器并上报剩余数据 } main(); ``` 旧版位置参数仍然可用: ```js const tracer = new StardustTracer('http://star.example.com:6600', 'MyApp', 'MySecret'); ``` Node.js 心跳会自动采集: | 字段 | 说明 | |------|------| | `Id` | 进程号 `process.pid` | | `Name` / `Version` | 应用名与版本 | | `CommandLine` | `process.argv.join(' ')` | | `UserName` / `MachineName` | 系统用户与主机名 | | `IP` | 本机非回环 IPv4 | | `StartTime` | 进程启动时间(ISO) | | `ProcessorTime` | 累计 CPU 时间(毫秒) | | `CpuUsage` | 采样周期 CPU 时间 / 实际时间 | | `WorkingSet` / `HeapSize` | RSS / heapTotal(字节) | | `Threads` | Node 固定为 0 | | `Handles` | 活跃句柄数(受保护采集) | | `Connections` | 固定为 0 | | `GCCount` | `perf_hooks` GC 事件增量 | | `Listens` | 空字符串 | | `Time` | 当前 Unix 毫秒 | 浏览器心跳 `Info`: | 字段 | 取值 | |------|------| | `Id` | 固定 0 | | `CommandLine` | `navigator.userAgent` | | `MachineName` | `navigator.userAgentData.platform` / `navigator.platform` | | `StartTime` | `new Date(performance.timeOrigin).toISOString()` | | `ProcessorTime` / `CpuUsage` | 固定 0 | | `WorkingSet` | `performance.memory.usedJSHeapSize` | | `HeapSize` | `performance.memory.totalJSHeapSize` | | `Threads` | `navigator.hardwareConcurrency` | | `Handles` / `Connections` / `GCCount` | 固定 0 | | `Listens` | `location.origin` | | `Time` | 当前 Unix 毫秒 | --- ## Express / Koa 中间件 ```js const express = require('express'); const { StardustTracer } = require('@newlifex/stardust'); const createExpressMiddleware = require('@newlifex/stardust/middleware/express'); const tracer = new StardustTracer({ server, appId, secret, version: '1.1.0' }); tracer.start(); const app = express(); app.use(createExpressMiddleware(tracer)); ``` ```js const Koa = require('koa'); const createKoaMiddleware = require('@newlifex/stardust/middleware/koa'); app.use(createKoaMiddleware(tracer)); ``` - 优先使用路由模板路径(`req.route.path` / `ctx.routePath` / `_matchedRoute`),回退到实际 path。 - 优先复用底层 `http.createServer` 自动入口创建的 Span,不会重复埋点;响应结束时把名称升级为路由模板。 - 状态码 `>= 400` 自动标记为错误。 - 自动忽略 `/App/`、`/Trace/` 监控接口。 - 响应结束事件只结算一次,不会重复上报。 --- ## Node.js 自动埋点 `autoInstrument: true`(默认)时,追踪器**在构造阶段**安装自动埋点;`start()` 仍然只负责登录、心跳与上报。无需修改业务代码。 ```js const { StardustTracer } = require('@newlifex/stardust'); const tracer = new StardustTracer({ server, appId, secret, autoInstrument: true }); // 之后创建的 http 服务器与业务请求自动产生埋点 const server = http.createServer((req, res) => res.end('ok')); ``` ### 支持范围 | 类型 | 名称 | Tag | 错误判定 | |------|------|-----|----------| | Web 入口 | `GET /users/:id`(路由模板,缺省为实际 path) | `GET /users/123?x=1` | 5xx 或未处理异常 | | 出站 HTTP | `GET api.example.com/users` | 完整 URL | 4xx/5xx 或网络异常 | | 数据库 | `db:postgresql:select:users`、`db:mysql:insert:orders` | SQL 文本(截断) | 驱动异常 | | Redis | `redis:GET` | `GET user:1`(命令 + 参数摘要,截断) | 客户端异常 | - **Web 入口**:包装 `http.createServer` / `https.createServer`,解析入站 `traceparent` 复用 TraceId,并把上游 SpanId 作为 ParentId;在 Span 上下文中执行监听器,异步业务天然串联。 - **出站 HTTP**:包装 `http.request/get`、`https.request/get` 与全局 `fetch`(Node 18+),以当前活动 Span 注入 `traceparent`;调用方已显式设置该头时不覆盖。 - **数据库**:自动检测并包装 `pg.Client.prototype.query`、`mysql2` 的 `Connection.prototype.query/execute`,同时支持回调与 Promise,`execute` 内部转调 `query` 不会重复计数。 - **Redis**:自动检测 `redis`(`RedisClient.prototype.sendCommand`,未导出时包装 `createClient`)与 `ioredis`(`Redis.prototype.sendCommand`)。 - 所有包装均为**幂等**,同一进程多次创建追踪器不会产生双份 Span;模块未安装时静默跳过。 ### 上下文 API ```js const { getCurrentSpan, parseTraceparent, formatTraceparent } = require('@newlifex/stardust'); // 手动建立上下文:内部创建的 Span/出站请求自动成为子 Span await tracer.withSpan('order.submit', async (span) => { span.setTag('orderId=123'); await fetch('https://api.example.com/pay'); // 自动继承 traceId/parentId }); // 显式指定上下文 const child = tracer.newSpan('child', { parentId: '...', traceId: '...' }); child.finish(); getCurrentSpan(); // 当前活动 Span(AsyncLocalStorage) parseTraceparent('00-<trace-id>-<span-id>-01'); // → { traceId, spanId, sampled, ... } formatTraceparent(traceId, spanId); // → '00-<trace-id>-<span-id>-01' ``` ### 关闭自动埋点 ```js // 方式一:构造时关闭(不安装任何补丁) const tracer = new StardustTracer({ server, appId, autoInstrument: false }); // 方式二:关闭后按需手动安装 const { installAutoInstrumentation } = require('@newlifex/stardust'); installAutoInstrumentation(tracer); ``` `/App/`、`/Trace/` 监控接口与 SDK 自身登录/心跳/上报请求始终被排除,不产生 Span、不注入 `traceparent`。 ### 限制 - 仅覆盖 Node 内置 `http`/`https`/`fetch`;其他 HTTP 客户端若最终走内置模块可被间接覆盖,自定义传输层需手动埋点。 - 入站自动埋点依赖 `http.createServer` / `https.createServer` 创建时传入的监听器(Express/Koa 应用函数即属此类);创建后再用 `server.on('request', fn)` 挂载的监听器不会被自动包装。 - `pg` 仅包装 `Client.prototype.query`(`Pool.query` 内部转发同样生效);`mysql2` 仅包装 `Connection`,未覆盖 `mysql`(v1)。 - 全局 Monkey Patch 对进程内所有请求生效;多个追踪器实例共用最近创建的一个统计。 - `fetch` 自动埋点依赖 Node 18+ 的全局 `fetch`,低版本自动跳过。 - 不采集 SQL 参数值,仅上报 SQL 文本与 Redis 参数摘要(按 `maxTagLength` 截断),避免泄露敏感数据。 --- ## 配置选项 | 选项 | 默认值 | Node | 浏览器 | 说明 | |------|--------|:----:|:------:|------| | `server` | — | ✓ | ✓ | 星尘服务器地址 | | `appId` | — | ✓ | ✓ | 应用 ID | | `appName` | 同 `appId` | ✓ | ✓ | 应用显示名 | | `secret` | `''` | ✓ | ✓ | 应用密钥;浏览器公网禁用 | | `version` | `1.1.0` | ✓ | ✓ | 应用版本,登录/上报必须携带 | | `clientId` | `localIP@pid` / `browser@...` | ✓ | ✓ | 客户端标识 | | `tokenProvider` | — | ✓ | ✓ | 自定义令牌提供者 | | `period` | `60` | ✓ | ✓ | 上报周期(秒),服务端可动态调整 | | `heartbeatPeriod` | `30` | ✓ | ✓ | 心跳周期(秒),浏览器隐藏时暂停 | | `maxSamples` | `1` | ✓ | ✓ | 每个埋点名称最大正常样本数 | | `maxErrors` | `10` | ✓ | ✓ | 每个埋点名称最大错误样本数 | | `maxTagLength` | `1024` | ✓ | ✓ | 标签最大长度 | | `requestTagLength` | `1024` | ✓ | ✓ | 请求标签最大长度 | | `timeout` | `5000` | ✓ | ✓ | 服务端判定的超时阈值(毫秒) | | `httpTimeout` | `10000` | ✓ | — | Node HTTP 请求超时(毫秒) | | `enableMeter` | `true` | ✓ | ✓ | 上报时附带 `Info` 性能信息 | | `autoInstrument` | `true` | ✓ | ✓ | 自动埋点:Node 为 HTTP/数据库/Redis,浏览器为 fetch/XHR/页面/路由/错误 | | `tracePropagationTargets` | `null` | — | ✓ | traceparent 传播目标,默认仅同源;支持 `'*'`、origin/主机名或正则 | | `initialTraceparent` | — | — | ✓ | 后端注入的 traceparent,把页面 Span 关联到服务端链路 | | `autoFlushOnExit` | `true` | ✓ | — | 进程退出时自动刷新 | | `debug` | `false` | ✓ | ✓ | 输出调试日志(默认静默) | | `excludes` | `[]` | ✓ | ✓ | 排除的埋点名称(子串匹配) | --- ## 浏览器自动埋点 `autoInstrument: true`(默认)或手动 `installBrowserInterceptors(tracer)`: | 类型 | 埋点名称 | 说明 | |------|----------|------| | fetch | `GET /api/users` | 同源取 pathname;跨域取 `host+pathname`;忽略查询串 | | XMLHttpRequest | `POST /api/order` | 同上;状态码 `>= 400` 或网络失败记错误 | | 页面加载 | `page.load` | Tag:`dns=..;tcp=..;ttfb=..;dom=..;load=..` | | SPA 路由 | `page.view /users` | 包装 `pushState` / `replaceState`,监听 `popstate` / `hashchange`,连续重复路径去重 | | JS 错误 | `js.error` | `window.error` 与 `unhandledrejection` | | 资源错误 | `resource.error` | 捕获阶段监听图片/脚本/样式加载失败 | 自动忽略包含 `/Trace/`、`/App/Login`、`/App/Ping` 的请求,避免自监控递归。 fetch/XHR 出站请求自动注入 `traceparent`(`00-<traceId>-<spanId>-01`),使用出站 Span 的 TraceId/SpanId;手动设置的 `traceparent` 不会被覆盖。浏览器侧可使用 `tracer.withSpan(name, fn)` 建立手动上下文。 手动安装: ```js const { installBrowserInterceptors } = require('@newlifex/stardust/browser'); installBrowserInterceptors(tracer); ``` --- ## 手动埋点 API ```js const span = tracer.newSpan('operation-name', parentId); span.setTag('key=value'); span.setError(err); span.finish(); // 幂等,重复调用只结算一次 // 上下文选项(Node 与浏览器一致) const child = tracer.newSpan('child', { parentId: '...', traceId: '...' }); // 自动管理上下文与结束(支持同步与 Promise) const result = await tracer.withSpan('operation-name', async (span) => { span.setTag('key=value'); return doSomething(); }); ``` - `newSpan(name, parentId?)` 返回 Span(`id`、`traceId`、`startTime` 等);第二个参数也可传 `{ parentId, traceId }`。 - Node 下未显式指定时自动继承 `AsyncLocalStorage` 中的当前 Span;浏览器维护活动 Span 栈,`withSpan` 入栈、结束时出栈。 - `setTag` / `setError` 支持链式调用。 - 数据自动按名称聚合:调用次数、错误数、总耗时、最大/最小耗时、样本。 --- ## 服务端响应参数 登录、心跳与上报响应中的以下字段会被自动应用: | 字段 | 说明 | |------|------| | `Period` | 上报周期(秒);变化时立即重建定时器 | | `MaxSamples` | 最大正常样本数 | | `MaxErrors` | 最大错误样本数 | | `Timeout` | 超时判定阈值(毫秒) | | `MaxTagLength` | 标签最大长度 | | `RequestTagLength` | 请求标签最大长度 | | `EnableMeter` | 是否在 Report 中携带 `Info` | | `Excludes` | 排除的埋点名称数组 | - 只有登录/上报请求携带 `Version` 时,服务端才会返回 `Excludes` / `EnableMeter`;SDK 始终携带 `Version`。 - 令牌在 5 分钟内过期会主动重新登录;业务码或 HTTP 状态为 401 时重新登录并重试一次。 ### 上报协议 - 普通上报:`POST /Trace/Report?Token=...`,JSON 正文。 - 大报文(Node,正文 > 1024 字节):`POST /Trace/ReportRaw?Token=...`,`Content-Type: application/x-gzip`,GZip 压缩正文。 - 上报正文: ```json { "AppId": "MyApp", "AppName": "MyApp", "ClientId": "192.168.1.100@12345", "Version": "1.1.0", "Info": { "Id": 12345, "Name": "MyApp", "Version": "1.1.0" }, "Builders": [ { "Name": "GET /api/users", "StartTime": 1707744000000, "EndTime": 1707744060000, "Total": 150, "Errors": 3, "Cost": 45000, "MaxCost": 500, "MinCost": 50, "Samples": [], "ErrorSamples": [] } ] } ``` --- ## 故障排查 | 现象 | 排查方向 | |------|----------| | 登录失败 | 检查 `server`、`appId`、`secret`;打开 `debug: true` 查看错误消息 | | 数据未上报 | 确认调用了 `start()`;Node 退出前调用 `await tracer.stop()`;检查 `period` | | 浏览器 CORS | 服务端需允许页面域名跨域,并允许 `Content-Type: application/json` | | 浏览器 401 循环 | 检查 `tokenProvider` 返回值与过期时间;确认后端令牌接口可用 | | 心跳无性能数据 | `enableMeter` 是否为 `true`;Node 心跳是否被 `excludes` 排除 | | 上报走 `ReportRaw` | 正文超过 1024 字节时的正常行为,服务端需支持 `application/x-gzip` | | ClientId 每次变化 | `localStorage` 是否被禁用或清理 | --- ## 测试 ```bash cd SDK/JavaScript npm test ``` 等价于依次执行: ```bash node test/test.js node test/integration.test.js node test/browser.test.js node test/instrument.test.js ``` - `test/test.js`:Span、SpanBuilder、Tracer 配置与导出。 - `test/integration.test.js`:本地 HTTP Mock 验证登录/心跳/上报字段、GZip、动态 Period、401 重登录、stop/flush。 - `test/browser.test.js`:伪造浏览器全局,验证 ClientId、登录版本、心跳 Info、上报、Beacon、fetch/XHR、路由与 JS 错误埋点。 - `test/instrument.test.js`:本地 HTTP 服务器与伪造 `pg`/`mysql2`/`redis`/`ioredis` 模块,验证 traceparent 解析/注入、出入站链路、中间件复用、排除规则、`autoInstrument:false` 以及数据库/Redis 埋点名称与错误。 语法检查: ```bash node --check src/browser.js node --check src/tracer.js ``` --- ## 许可证 MIT License ## 相关链接 - [星尘监控平台](http://star.newlifex.com) - [GitHub 仓库](https://github.com/NewLifeX/Stardust) - [完整文档](https://newlifex.com)