@nebulaaa/sentry 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,128 @@
1
- # Temporary Holding Version
1
+ # @nebulaaa/sentry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 哨兵(异常监控)插件:捕获全局 JavaScript 错误、未处理的 Promise 拒绝与原生错误,统一转发给 `@nebulaaa/reporter` 上报。
4
+
5
+ ## 设计概览
6
+
7
+ - **三通道捕获**:Web 走 `window` 的 `error` / `unhandledrejection` 事件;原生走 jsb 运行时的 `__errorHandler` 链式接管;小游戏平台当前无覆盖(见注意事项)。
8
+ - **跨 realm 安全**:拒绝原因可能非 `Error`(跨 realm 实例、字符串、任意对象),序列化逐级回退:`message` 属性 → `String` → `JSON.stringify` → `Object.prototype.toString`,任何一级抛错都不逃逸出事件监听器。
9
+ - **链式兼容**:原生处理器接管 `__errorHandler` 前先保存旧处理器,捕获后原样回调,不破坏宿主既有链路。
10
+ - **监控组件底线**:自身故障(监听器抛错、写入失败)不得拖垮宿主;捕获计数与转发双层防御,开发/测试环境只记日志不上报。
11
+ - **依赖收窄**:仅依赖 `@nebulaaa/access`(全局访问)、`@nebulaaa/reporter`(上报)、`@nebulaaa/launcher`(环境守卫)与 `@nebulaaa/ms`(时间戳)。
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install @nebulaaa/sentry
17
+ ```
18
+
19
+ 依赖 `@nebulaaa/reporter` 建议 1.2.0 及以上(含切后台落盘、优先级排序等修复)。
20
+
21
+ ## 基本用法
22
+
23
+ ```ts
24
+ import { SentryPlugin } from '@nebulaaa/sentry';
25
+
26
+ const sentry = new SentryPlugin(
27
+ { Access: access, Reporter: reporter, Launcher: launcher },
28
+ {},
29
+ );
30
+ // 无需宿主接线:构造时自动注册全局错误监听(Web)或接管 __errorHandler(原生)
31
+
32
+ // 查看捕获统计(快照副本)
33
+ sentry.Stats; // { Total, Javascript, Promise, Native }
34
+ ```
35
+
36
+ > **单例约束**:插件构造时向全局注册监听且无卸载钩子,整个应用生命周期内仅应实例化一次;重复实例化会导致监听重复注册、原生错误处理器链式叠加。
37
+
38
+ ## 捕获通道
39
+
40
+ ### Web(window 事件)
41
+
42
+ - `error` → `SentryType.JAVASCRIPT`:提取 `message` / `error.stack` / `filename` / `lineno` / `colno`。
43
+ - `unhandledrejection` → `SentryType.PROMISE`:按 `Error` / `string` / 其它对象分别提取消息与堆栈。
44
+
45
+ 实现说明:`addEventListener` 位于 `EventTarget.prototype`(非全局自有属性),`@access` 委托的自有属性语义(`hasOwnProperty`)不可达,故本插件直接访问 `globalThis` 并 `bind` 接收者——剥离调用在 ESM 严格模式下会因 `this` 为 `undefined` 抛 `Illegal invocation`。
46
+
47
+ ### 原生(`__errorHandler` 链式接管)
48
+
49
+ - `sys.isNative` 时先读旧 `__errorHandler`(非函数则不参与链式),再写入本插件处理器;捕获后**先**原样回调旧处理器(保持宿主链路时序),**再**构造载荷转发。
50
+ - 全局写入失败(`Set` 返回 `false`)时回退链引用并 `Warn`——旧处理器原位不受影响,本插件原生捕获标记为未生效。
51
+
52
+ ## 公共接口
53
+
54
+ ### `SentryPlugin`
55
+
56
+ | 成员 | 签名 | 说明 |
57
+ |---|---|---|
58
+ | `constructor` | `(deps: SentryDeps, cfg: SentryCfg)` | 构造即注册全局捕获 |
59
+ | `Stats` | `get Stats(): SentryStats` | 捕获统计(快照副本,消费方不可改内部状态) |
60
+
61
+ ### 类型
62
+
63
+ ```ts
64
+ enum SentryType {
65
+ JAVASCRIPT = 'JAVASCRIPT',
66
+ PROMISE = 'PROMISE',
67
+ NATIVE = 'NATIVE',
68
+ }
69
+
70
+ interface SentryPayload {
71
+ Type: SentryType;
72
+ Code: string; // JAVASCRIPT_ERROR / PROMISE_REJECTION / NATIVE_ERROR
73
+ Message: string;
74
+ Timestamp: number;
75
+ Stack?: string;
76
+ Data?: Record<string, unknown>; // 预留字段,本插件当前不填充
77
+ Filename?: string;
78
+ Lineno?: number;
79
+ Colno?: number;
80
+ }
81
+
82
+ interface SentryStats {
83
+ Total: number; // 捕获总数(含开发环境只记日志的捕获)
84
+ Javascript: number;
85
+ Promise: number;
86
+ Native: number;
87
+ }
88
+
89
+ interface SentryDeps extends PluginDeps {
90
+ Access: IAccessPlugin; // 全局对象访问
91
+ Reporter: IReporterPlugin; // 上报中心
92
+ Launcher: ILauncherPlugin; // 环境守卫(IsDev / IsAlpha)
93
+ }
94
+
95
+ interface SentryCfg extends PluginCfg {} // 当前无可配置项
96
+ ```
97
+
98
+ ## 转发语义
99
+
100
+ 捕获成功后(所有通道统一走 `sendToReporter`):
101
+
102
+ 1. `Logger.Error('捕获到异常', payload)`——任何环境都记录,保留本地排查线索;
103
+ 2. `IsDev || IsAlpha` 环境直接返回(与 `@reporter.Collect` 的守卫对齐,双层防御);
104
+ 3. 其余环境 `Reporter.Collect(ReporterType.ERROR, code, message, payload)`,由 reporter 负责攒批、重试与送达。
105
+
106
+ `Stats` 在捕获时就递增(含开发环境),因此统计口径是**捕获数**而非**上报数**。
107
+
108
+ ## 注意事项
109
+
110
+ - **单例约束**:见"基本用法",重复实例化会叠加全局监听与原生处理器链。
111
+ - **小游戏平台无覆盖**:微信/抖音等小游戏全局对象无 `addEventListener` 且 `sys.isNative` 为 `false`,两个捕获通道均不生效;如需支持请接入对应平台的 `onError` / `onUnhandledRejection` API。
112
+ - **跨源脚本错误**:浏览器安全策略下跨域脚本的 `error` 事件 `error` 为 `null`、`message` 为 `Script error.`,需服务端响应 `Cross-Origin-Resource-Policy` / 脚本 `crossorigin` 属性配合才能拿到完整堆栈。
113
+ - **拒绝原因序列化**:跨 realm `Error` 不是 `instanceof Error`,经鸭子类型提取 `message` / `stack`;自带 `message` 读取器抛错、`String`/`JSON` 转换抛错均有末级回退,不会逃逸。
114
+ - **Data 字段**:`SentryPayload.Data` 为对外保留的扩展位,本插件当前不填充。
115
+
116
+ ## 构建与测试
117
+
118
+ ```bash
119
+ npm run build
120
+ npm test
121
+ ```
122
+
123
+ 在仓库内 `@sentry` 目录下也可直接运行:
124
+
125
+ ```bash
126
+ npx tsc --noEmit -p tsconfig.json
127
+ npx jest --config jest.config.mjs --passWithNoTests --runInBand
128
+ ```
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@nebulaaa/sentry",
3
+ "version": "1.0.1",
4
+ "description": "哨兵(异常监控)插件",
5
+ "author": "doooreyn",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "sentry",
9
+ "nebulaaa"
10
+ ],
11
+ "engines": {
12
+ "node": ">=16.0.0",
13
+ "npm": ">=10.0.0"
14
+ },
15
+ "main": "./sentry.cjs",
16
+ "module": "./sentry.mjs",
17
+ "types": "./sentry.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./sentry.d.ts",
21
+ "require": "./sentry.cjs",
22
+ "import": "./sentry.mjs",
23
+ "default": "./sentry.cjs"
24
+ },
25
+ "./min": {
26
+ "types": "./sentry.d.ts",
27
+ "require": "./sentry.min.cjs",
28
+ "import": "./sentry.min.mjs"
29
+ }
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org/",
33
+ "access": "public"
34
+ }
35
+ }
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ var cc = require('cc');
4
+ var ms = require('@nebulaaa/ms');
5
+ var plugin = require('@nebulaaa/plugin');
6
+ var reporter = require('@nebulaaa/reporter');
7
+
8
+ /**
9
+ * 异常类型
10
+ */
11
+ exports.SentryType = void 0;
12
+ (function (SentryType) {
13
+ /** JavaScript 错误 */
14
+ SentryType["JAVASCRIPT"] = "JAVASCRIPT";
15
+ /** Promise 拒绝 */
16
+ SentryType["PROMISE"] = "PROMISE";
17
+ /** 原生错误 */
18
+ SentryType["NATIVE"] = "NATIVE";
19
+ })(exports.SentryType || (exports.SentryType = {}));
20
+
21
+ /**
22
+ * 哨兵能力实现
23
+ *
24
+ * 职责:
25
+ * - 捕获全局 JavaScript 错误
26
+ * - 捕获未处理的 Promise 拒绝
27
+ * - 通知上报中心报告异常
28
+ *
29
+ * @tip 单例组件:构造时向全局对象注册错误监听(Web)或改写 __errorHandler(原生),
30
+ * 且无卸载钩子,整个应用生命周期内仅应实例化一次;
31
+ * 重复实例化会导致监听重复注册、原生错误处理器链式叠加。
32
+ * @tip 平台覆盖:Web 走 window 事件、原生走 __errorHandler;
33
+ * 小游戏平台(微信/抖音等,全局对象无 addEventListener 且 sys.isNative 为 false)
34
+ * 当前无捕获覆盖,如需支持请接入对应平台 API。
35
+ */
36
+ class SentryPlugin extends plugin.Plugin {
37
+ constructor(deps, cfg) {
38
+ super(deps, cfg);
39
+ this._stats = {
40
+ Total: 0,
41
+ Javascript: 0,
42
+ Promise: 0,
43
+ Native: 0,
44
+ };
45
+ this._jsErrorHandler = null;
46
+ this._promiseRejectionHandler = null;
47
+ this._nativeErrorHandler = null;
48
+ const global = this.Deps.Access.Global;
49
+ // Web 捕获:addEventListener 位于 EventTarget.prototype(非全局自有属性),
50
+ // Access.Delegate.Has/Get 的自有属性语义取不到(实测浏览器 hasOwnProperty 为 false),
51
+ // 故此处直接访问 globalThis 并绑定接收者——剥离调用在 ESM 严格模式下
52
+ // 因 this 为 undefined 抛 Illegal invocation
53
+ if (typeof globalThis.addEventListener === 'function') {
54
+ const addEventListener = globalThis.addEventListener.bind(globalThis);
55
+ // 注册全局错误处理器和 Promise 拒绝处理器
56
+ this._jsErrorHandler = this.handleJavaScriptError.bind(this);
57
+ this._promiseRejectionHandler = this.handlePromiseRejection.bind(this);
58
+ addEventListener('error', this._jsErrorHandler);
59
+ addEventListener('unhandledrejection', this._promiseRejectionHandler);
60
+ }
61
+ if (cc.sys.isNative) {
62
+ // 注册原生错误处理器:__errorHandler 由 jsb 运行时以自有属性注入,Access 语义可用;
63
+ // 原有处理器必须为函数才参与链式调用,避免错误发生时才抛 TypeError
64
+ const legacy = global.Get('__errorHandler');
65
+ this._nativeErrorHandler =
66
+ typeof legacy === 'function' ? legacy : null;
67
+ // Set 失败(返回 false)说明全局写入被拒:旧处理器保持原位不受影响,
68
+ // 但本插件的原生捕获未生效,回退链引用并告警
69
+ if (!global.Set('__errorHandler', this.handleNativeError.bind(this))) {
70
+ this._nativeErrorHandler = null;
71
+ this.Logger.Warn('原生错误处理器写入失败,原生捕获未生效');
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * 处理 JavaScript 错误
77
+ */
78
+ handleJavaScriptError(event) {
79
+ var _a;
80
+ this._stats.Total++;
81
+ this._stats.Javascript++;
82
+ const error = {
83
+ Type: exports.SentryType.JAVASCRIPT,
84
+ Code: 'JAVASCRIPT_ERROR',
85
+ Message: event.message || 'Unknown error',
86
+ Timestamp: ms.Now(),
87
+ Stack: (_a = event.error) === null || _a === void 0 ? void 0 : _a.stack,
88
+ Filename: event.filename,
89
+ Lineno: event.lineno,
90
+ Colno: event.colno,
91
+ };
92
+ this.sendToReporter(error);
93
+ }
94
+ /**
95
+ * 处理 Promise 拒绝
96
+ */
97
+ handlePromiseRejection(event) {
98
+ this._stats.Total++;
99
+ this._stats.Promise++;
100
+ const { reason } = event;
101
+ let message;
102
+ let stack;
103
+ if (reason instanceof Error) {
104
+ const { message: _message, stack: _stack } = reason;
105
+ message = _message;
106
+ stack = _stack;
107
+ }
108
+ else if (typeof reason === 'string') {
109
+ message = reason;
110
+ }
111
+ else {
112
+ message = this.stringifyReason(reason);
113
+ stack = this.extractStack(reason);
114
+ }
115
+ const error = {
116
+ Type: exports.SentryType.PROMISE,
117
+ Code: 'PROMISE_REJECTION',
118
+ Message: message,
119
+ Timestamp: ms.Now(),
120
+ Stack: stack,
121
+ };
122
+ this.sendToReporter(error);
123
+ }
124
+ /**
125
+ * 安全序列化拒绝原因
126
+ *
127
+ * @tip String(reason) 可因 toPrimitive/toString 抛出异常(异常将从事件监听器逃逸,
128
+ * 造成计数与转发不一致);普通对象还会退化为无信息量的 '[object Object]'。
129
+ * 故逐级回退:message 属性 → String → JSON.stringify → Object.prototype.toString
130
+ */
131
+ stringifyReason(reason) {
132
+ // 跨 realm Error 实例 / 自带 message 的对象,优先提取 message
133
+ if (reason !== null && typeof reason === 'object') {
134
+ try {
135
+ const { message } = reason;
136
+ if (typeof message === 'string' && message.length > 0) {
137
+ return message;
138
+ }
139
+ }
140
+ catch (e) {
141
+ // message 读取器抛错,走后续回退
142
+ }
143
+ }
144
+ // String 可抛(toPrimitive/toString);结果为 '[object Object]' 时无信息量
145
+ try {
146
+ const text = String(reason);
147
+ if (text !== '[object Object]') {
148
+ return text;
149
+ }
150
+ }
151
+ catch (e) {
152
+ // 忽略,走后续回退
153
+ }
154
+ // JSON 可抛(循环引用 / toJSON 抛错)
155
+ try {
156
+ const json = JSON.stringify(reason);
157
+ if (typeof json === 'string') {
158
+ return json;
159
+ }
160
+ }
161
+ catch (e) {
162
+ // 忽略,走末级回退
163
+ }
164
+ return Object.prototype.toString.call(reason);
165
+ }
166
+ /**
167
+ * 提取 Error 形态对象的堆栈
168
+ * @tip 跨 realm Error 实例不是 instanceof Error,其 message/stack 属性原样可取
169
+ */
170
+ extractStack(reason) {
171
+ if (reason !== null && typeof reason === 'object') {
172
+ try {
173
+ const { stack } = reason;
174
+ if (typeof stack === 'string') {
175
+ return stack;
176
+ }
177
+ }
178
+ catch (e) {
179
+ // stack 读取器抛错,保持 undefined
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+ /**
185
+ * 处理原生错误
186
+ */
187
+ handleNativeError(name, line, msg, stack) {
188
+ this._stats.Total++;
189
+ this._stats.Native++;
190
+ if (typeof this._nativeErrorHandler === 'function') {
191
+ this._nativeErrorHandler(name, line, msg, stack);
192
+ }
193
+ const error = {
194
+ Type: exports.SentryType.NATIVE,
195
+ Code: 'NATIVE_ERROR',
196
+ Message: msg,
197
+ Filename: name,
198
+ Lineno: line,
199
+ Timestamp: ms.Now(),
200
+ Stack: stack,
201
+ };
202
+ this.sendToReporter(error);
203
+ }
204
+ /**
205
+ * 报告给记者
206
+ */
207
+ sendToReporter(error) {
208
+ this.Logger.Error('捕获到异常', error);
209
+ // 开发、测试环境不上报(与 @reporter.Collect 的 IsDev||IsAlpha 守卫对齐,双层防御)
210
+ if (this.Deps.Launcher.IsDev || this.Deps.Launcher.IsAlpha)
211
+ return;
212
+ this.Deps.Reporter.Collect(reporter.ReporterType.ERROR, error.Code, error.Message, Object.assign({}, error));
213
+ }
214
+ get Stats() {
215
+ return Object.assign({}, this._stats);
216
+ }
217
+ }
218
+
219
+ exports.SentryPlugin = SentryPlugin;
@@ -0,0 +1,125 @@
1
+ import { IAccessPlugin } from '@nebulaaa/access';
2
+ import { ILauncherPlugin } from '@nebulaaa/launcher';
3
+ import { IPlugin, PluginDeps, PluginCfg, Plugin } from '@nebulaaa/plugin';
4
+ import { IReporterPlugin } from '@nebulaaa/reporter';
5
+
6
+ /**
7
+ * 异常类型
8
+ */
9
+ declare enum SentryType {
10
+ /** JavaScript 错误 */
11
+ JAVASCRIPT = "JAVASCRIPT",
12
+ /** Promise 拒绝 */
13
+ PROMISE = "PROMISE",
14
+ /** 原生错误 */
15
+ NATIVE = "NATIVE"
16
+ }
17
+ /**
18
+ * 异常数据
19
+ */
20
+ interface SentryPayload {
21
+ /** 异常类型 */
22
+ Type: SentryType;
23
+ /** 异常代码 */
24
+ Code: string;
25
+ /** 异常消息 */
26
+ Message: string;
27
+ /** 异常时间戳 */
28
+ Timestamp: number;
29
+ /** 异常堆栈 */
30
+ Stack?: string;
31
+ /** 额外数据 */
32
+ Data?: Record<string, unknown>;
33
+ /** 文件名 */
34
+ Filename?: string;
35
+ /** 行号 */
36
+ Lineno?: number;
37
+ /** 列号 */
38
+ Colno?: number;
39
+ }
40
+ /**
41
+ * 哨兵统计
42
+ */
43
+ interface SentryStats {
44
+ /** 捕获的错误总数 */
45
+ Total: number;
46
+ /** 捕获的 JavaScript 错误总数 */
47
+ Javascript: number;
48
+ /** 捕获的 Promise 拒绝总数 */
49
+ Promise: number;
50
+ /** 捕获的原生错误总数 */
51
+ Native: number;
52
+ }
53
+ /** 哨兵插件依赖 */
54
+ interface SentryDeps extends PluginDeps {
55
+ Access: IAccessPlugin;
56
+ Reporter: IReporterPlugin;
57
+ Launcher: ILauncherPlugin;
58
+ }
59
+ /** 哨兵插件配置 */
60
+ interface SentryCfg extends PluginCfg {
61
+ }
62
+ /** 哨兵插件接口 */
63
+ interface ISentryPlugin extends IPlugin<SentryDeps, SentryCfg> {
64
+ get Stats(): SentryStats;
65
+ }
66
+
67
+ /**
68
+ * 哨兵能力实现
69
+ *
70
+ * 职责:
71
+ * - 捕获全局 JavaScript 错误
72
+ * - 捕获未处理的 Promise 拒绝
73
+ * - 通知上报中心报告异常
74
+ *
75
+ * @tip 单例组件:构造时向全局对象注册错误监听(Web)或改写 __errorHandler(原生),
76
+ * 且无卸载钩子,整个应用生命周期内仅应实例化一次;
77
+ * 重复实例化会导致监听重复注册、原生错误处理器链式叠加。
78
+ * @tip 平台覆盖:Web 走 window 事件、原生走 __errorHandler;
79
+ * 小游戏平台(微信/抖音等,全局对象无 addEventListener 且 sys.isNative 为 false)
80
+ * 当前无捕获覆盖,如需支持请接入对应平台 API。
81
+ */
82
+ declare class SentryPlugin extends Plugin<SentryDeps, SentryCfg> implements ISentryPlugin {
83
+ /** 统计 */
84
+ private _stats;
85
+ /** JavaScript 错误处理器 */
86
+ private _jsErrorHandler;
87
+ /** Promise 拒绝处理器 */
88
+ private _promiseRejectionHandler;
89
+ /** 原生错误捕获 */
90
+ private _nativeErrorHandler;
91
+ constructor(deps: SentryDeps, cfg: SentryCfg);
92
+ /**
93
+ * 处理 JavaScript 错误
94
+ */
95
+ private handleJavaScriptError;
96
+ /**
97
+ * 处理 Promise 拒绝
98
+ */
99
+ private handlePromiseRejection;
100
+ /**
101
+ * 安全序列化拒绝原因
102
+ *
103
+ * @tip String(reason) 可因 toPrimitive/toString 抛出异常(异常将从事件监听器逃逸,
104
+ * 造成计数与转发不一致);普通对象还会退化为无信息量的 '[object Object]'。
105
+ * 故逐级回退:message 属性 → String → JSON.stringify → Object.prototype.toString
106
+ */
107
+ private stringifyReason;
108
+ /**
109
+ * 提取 Error 形态对象的堆栈
110
+ * @tip 跨 realm Error 实例不是 instanceof Error,其 message/stack 属性原样可取
111
+ */
112
+ private extractStack;
113
+ /**
114
+ * 处理原生错误
115
+ */
116
+ private handleNativeError;
117
+ /**
118
+ * 报告给记者
119
+ */
120
+ private sendToReporter;
121
+ get Stats(): SentryStats;
122
+ }
123
+
124
+ export { SentryPlugin, SentryType };
125
+ export type { ISentryPlugin, SentryCfg, SentryDeps, SentryPayload, SentryStats };
@@ -0,0 +1 @@
1
+ "use strict";var e,t=require("cc"),r=require("@nebulaaa/ms"),s=require("@nebulaaa/plugin"),n=require("@nebulaaa/reporter");exports.SentryType=void 0,(e=exports.SentryType||(exports.SentryType={})).JAVASCRIPT="JAVASCRIPT",e.PROMISE="PROMISE",e.NATIVE="NATIVE";class i extends s.Plugin{constructor(e,r){super(e,r),this._stats={Total:0,Javascript:0,Promise:0,Native:0},this._jsErrorHandler=null,this._promiseRejectionHandler=null,this._nativeErrorHandler=null;const s=this.Deps.Access.Global;if("function"==typeof globalThis.addEventListener){const e=globalThis.addEventListener.bind(globalThis);this._jsErrorHandler=this.handleJavaScriptError.bind(this),this._promiseRejectionHandler=this.handlePromiseRejection.bind(this),e("error",this._jsErrorHandler),e("unhandledrejection",this._promiseRejectionHandler)}if(t.sys.isNative){const e=s.Get("__errorHandler");this._nativeErrorHandler="function"==typeof e?e:null,s.Set("__errorHandler",this.handleNativeError.bind(this))||(this._nativeErrorHandler=null,this.Logger.Warn("原生错误处理器写入失败,原生捕获未生效"))}}handleJavaScriptError(e){var t;this._stats.Total++,this._stats.Javascript++;const s={Type:exports.SentryType.JAVASCRIPT,Code:"JAVASCRIPT_ERROR",Message:e.message||"Unknown error",Timestamp:r.Now(),Stack:null===(t=e.error)||void 0===t?void 0:t.stack,Filename:e.filename,Lineno:e.lineno,Colno:e.colno};this.sendToReporter(s)}handlePromiseRejection(e){this._stats.Total++,this._stats.Promise++;const{reason:t}=e;let s,n;if(t instanceof Error){const{message:e,stack:r}=t;s=e,n=r}else"string"==typeof t?s=t:(s=this.stringifyReason(t),n=this.extractStack(t));const i={Type:exports.SentryType.PROMISE,Code:"PROMISE_REJECTION",Message:s,Timestamp:r.Now(),Stack:n};this.sendToReporter(i)}stringifyReason(e){if(null!==e&&"object"==typeof e)try{const{message:t}=e;if("string"==typeof t&&t.length>0)return t}catch(e){}try{const t=String(e);if("[object Object]"!==t)return t}catch(e){}try{const t=JSON.stringify(e);if("string"==typeof t)return t}catch(e){}return Object.prototype.toString.call(e)}extractStack(e){if(null!==e&&"object"==typeof e)try{const{stack:t}=e;if("string"==typeof t)return t}catch(e){}}handleNativeError(e,t,s,n){this._stats.Total++,this._stats.Native++,"function"==typeof this._nativeErrorHandler&&this._nativeErrorHandler(e,t,s,n);const i={Type:exports.SentryType.NATIVE,Code:"NATIVE_ERROR",Message:s,Filename:e,Lineno:t,Timestamp:r.Now(),Stack:n};this.sendToReporter(i)}sendToReporter(e){this.Logger.Error("捕获到异常",e),this.Deps.Launcher.IsDev||this.Deps.Launcher.IsAlpha||this.Deps.Reporter.Collect(n.ReporterType.ERROR,e.Code,e.Message,Object.assign({},e))}get Stats(){return Object.assign({},this._stats)}}exports.SentryPlugin=i;
@@ -0,0 +1 @@
1
+ import{sys as t}from"cc";import{Now as e}from"@nebulaaa/ms";import{Plugin as r}from"@nebulaaa/plugin";import{ReporterType as s}from"@nebulaaa/reporter";var n;!function(t){t.JAVASCRIPT="JAVASCRIPT",t.PROMISE="PROMISE",t.NATIVE="NATIVE"}(n||(n={}));class i extends r{constructor(e,r){super(e,r),this._stats={Total:0,Javascript:0,Promise:0,Native:0},this._jsErrorHandler=null,this._promiseRejectionHandler=null,this._nativeErrorHandler=null;const s=this.Deps.Access.Global;if("function"==typeof globalThis.addEventListener){const t=globalThis.addEventListener.bind(globalThis);this._jsErrorHandler=this.handleJavaScriptError.bind(this),this._promiseRejectionHandler=this.handlePromiseRejection.bind(this),t("error",this._jsErrorHandler),t("unhandledrejection",this._promiseRejectionHandler)}if(t.isNative){const t=s.Get("__errorHandler");this._nativeErrorHandler="function"==typeof t?t:null,s.Set("__errorHandler",this.handleNativeError.bind(this))||(this._nativeErrorHandler=null,this.Logger.Warn("原生错误处理器写入失败,原生捕获未生效"))}}handleJavaScriptError(t){var r;this._stats.Total++,this._stats.Javascript++;const s={Type:n.JAVASCRIPT,Code:"JAVASCRIPT_ERROR",Message:t.message||"Unknown error",Timestamp:e(),Stack:null===(r=t.error)||void 0===r?void 0:r.stack,Filename:t.filename,Lineno:t.lineno,Colno:t.colno};this.sendToReporter(s)}handlePromiseRejection(t){this._stats.Total++,this._stats.Promise++;const{reason:r}=t;let s,i;if(r instanceof Error){const{message:t,stack:e}=r;s=t,i=e}else"string"==typeof r?s=r:(s=this.stringifyReason(r),i=this.extractStack(r));const o={Type:n.PROMISE,Code:"PROMISE_REJECTION",Message:s,Timestamp:e(),Stack:i};this.sendToReporter(o)}stringifyReason(t){if(null!==t&&"object"==typeof t)try{const{message:e}=t;if("string"==typeof e&&e.length>0)return e}catch(t){}try{const e=String(t);if("[object Object]"!==e)return e}catch(t){}try{const e=JSON.stringify(t);if("string"==typeof e)return e}catch(t){}return Object.prototype.toString.call(t)}extractStack(t){if(null!==t&&"object"==typeof t)try{const{stack:e}=t;if("string"==typeof e)return e}catch(t){}}handleNativeError(t,r,s,i){this._stats.Total++,this._stats.Native++,"function"==typeof this._nativeErrorHandler&&this._nativeErrorHandler(t,r,s,i);const o={Type:n.NATIVE,Code:"NATIVE_ERROR",Message:s,Filename:t,Lineno:r,Timestamp:e(),Stack:i};this.sendToReporter(o)}sendToReporter(t){this.Logger.Error("捕获到异常",t),this.Deps.Launcher.IsDev||this.Deps.Launcher.IsAlpha||this.Deps.Reporter.Collect(s.ERROR,t.Code,t.Message,Object.assign({},t))}get Stats(){return Object.assign({},this._stats)}}export{i as SentryPlugin,n as SentryType};
@@ -0,0 +1,217 @@
1
+ import { sys } from 'cc';
2
+ import { Now } from '@nebulaaa/ms';
3
+ import { Plugin } from '@nebulaaa/plugin';
4
+ import { ReporterType } from '@nebulaaa/reporter';
5
+
6
+ /**
7
+ * 异常类型
8
+ */
9
+ var SentryType;
10
+ (function (SentryType) {
11
+ /** JavaScript 错误 */
12
+ SentryType["JAVASCRIPT"] = "JAVASCRIPT";
13
+ /** Promise 拒绝 */
14
+ SentryType["PROMISE"] = "PROMISE";
15
+ /** 原生错误 */
16
+ SentryType["NATIVE"] = "NATIVE";
17
+ })(SentryType || (SentryType = {}));
18
+
19
+ /**
20
+ * 哨兵能力实现
21
+ *
22
+ * 职责:
23
+ * - 捕获全局 JavaScript 错误
24
+ * - 捕获未处理的 Promise 拒绝
25
+ * - 通知上报中心报告异常
26
+ *
27
+ * @tip 单例组件:构造时向全局对象注册错误监听(Web)或改写 __errorHandler(原生),
28
+ * 且无卸载钩子,整个应用生命周期内仅应实例化一次;
29
+ * 重复实例化会导致监听重复注册、原生错误处理器链式叠加。
30
+ * @tip 平台覆盖:Web 走 window 事件、原生走 __errorHandler;
31
+ * 小游戏平台(微信/抖音等,全局对象无 addEventListener 且 sys.isNative 为 false)
32
+ * 当前无捕获覆盖,如需支持请接入对应平台 API。
33
+ */
34
+ class SentryPlugin extends Plugin {
35
+ constructor(deps, cfg) {
36
+ super(deps, cfg);
37
+ this._stats = {
38
+ Total: 0,
39
+ Javascript: 0,
40
+ Promise: 0,
41
+ Native: 0,
42
+ };
43
+ this._jsErrorHandler = null;
44
+ this._promiseRejectionHandler = null;
45
+ this._nativeErrorHandler = null;
46
+ const global = this.Deps.Access.Global;
47
+ // Web 捕获:addEventListener 位于 EventTarget.prototype(非全局自有属性),
48
+ // Access.Delegate.Has/Get 的自有属性语义取不到(实测浏览器 hasOwnProperty 为 false),
49
+ // 故此处直接访问 globalThis 并绑定接收者——剥离调用在 ESM 严格模式下
50
+ // 因 this 为 undefined 抛 Illegal invocation
51
+ if (typeof globalThis.addEventListener === 'function') {
52
+ const addEventListener = globalThis.addEventListener.bind(globalThis);
53
+ // 注册全局错误处理器和 Promise 拒绝处理器
54
+ this._jsErrorHandler = this.handleJavaScriptError.bind(this);
55
+ this._promiseRejectionHandler = this.handlePromiseRejection.bind(this);
56
+ addEventListener('error', this._jsErrorHandler);
57
+ addEventListener('unhandledrejection', this._promiseRejectionHandler);
58
+ }
59
+ if (sys.isNative) {
60
+ // 注册原生错误处理器:__errorHandler 由 jsb 运行时以自有属性注入,Access 语义可用;
61
+ // 原有处理器必须为函数才参与链式调用,避免错误发生时才抛 TypeError
62
+ const legacy = global.Get('__errorHandler');
63
+ this._nativeErrorHandler =
64
+ typeof legacy === 'function' ? legacy : null;
65
+ // Set 失败(返回 false)说明全局写入被拒:旧处理器保持原位不受影响,
66
+ // 但本插件的原生捕获未生效,回退链引用并告警
67
+ if (!global.Set('__errorHandler', this.handleNativeError.bind(this))) {
68
+ this._nativeErrorHandler = null;
69
+ this.Logger.Warn('原生错误处理器写入失败,原生捕获未生效');
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * 处理 JavaScript 错误
75
+ */
76
+ handleJavaScriptError(event) {
77
+ var _a;
78
+ this._stats.Total++;
79
+ this._stats.Javascript++;
80
+ const error = {
81
+ Type: SentryType.JAVASCRIPT,
82
+ Code: 'JAVASCRIPT_ERROR',
83
+ Message: event.message || 'Unknown error',
84
+ Timestamp: Now(),
85
+ Stack: (_a = event.error) === null || _a === void 0 ? void 0 : _a.stack,
86
+ Filename: event.filename,
87
+ Lineno: event.lineno,
88
+ Colno: event.colno,
89
+ };
90
+ this.sendToReporter(error);
91
+ }
92
+ /**
93
+ * 处理 Promise 拒绝
94
+ */
95
+ handlePromiseRejection(event) {
96
+ this._stats.Total++;
97
+ this._stats.Promise++;
98
+ const { reason } = event;
99
+ let message;
100
+ let stack;
101
+ if (reason instanceof Error) {
102
+ const { message: _message, stack: _stack } = reason;
103
+ message = _message;
104
+ stack = _stack;
105
+ }
106
+ else if (typeof reason === 'string') {
107
+ message = reason;
108
+ }
109
+ else {
110
+ message = this.stringifyReason(reason);
111
+ stack = this.extractStack(reason);
112
+ }
113
+ const error = {
114
+ Type: SentryType.PROMISE,
115
+ Code: 'PROMISE_REJECTION',
116
+ Message: message,
117
+ Timestamp: Now(),
118
+ Stack: stack,
119
+ };
120
+ this.sendToReporter(error);
121
+ }
122
+ /**
123
+ * 安全序列化拒绝原因
124
+ *
125
+ * @tip String(reason) 可因 toPrimitive/toString 抛出异常(异常将从事件监听器逃逸,
126
+ * 造成计数与转发不一致);普通对象还会退化为无信息量的 '[object Object]'。
127
+ * 故逐级回退:message 属性 → String → JSON.stringify → Object.prototype.toString
128
+ */
129
+ stringifyReason(reason) {
130
+ // 跨 realm Error 实例 / 自带 message 的对象,优先提取 message
131
+ if (reason !== null && typeof reason === 'object') {
132
+ try {
133
+ const { message } = reason;
134
+ if (typeof message === 'string' && message.length > 0) {
135
+ return message;
136
+ }
137
+ }
138
+ catch (e) {
139
+ // message 读取器抛错,走后续回退
140
+ }
141
+ }
142
+ // String 可抛(toPrimitive/toString);结果为 '[object Object]' 时无信息量
143
+ try {
144
+ const text = String(reason);
145
+ if (text !== '[object Object]') {
146
+ return text;
147
+ }
148
+ }
149
+ catch (e) {
150
+ // 忽略,走后续回退
151
+ }
152
+ // JSON 可抛(循环引用 / toJSON 抛错)
153
+ try {
154
+ const json = JSON.stringify(reason);
155
+ if (typeof json === 'string') {
156
+ return json;
157
+ }
158
+ }
159
+ catch (e) {
160
+ // 忽略,走末级回退
161
+ }
162
+ return Object.prototype.toString.call(reason);
163
+ }
164
+ /**
165
+ * 提取 Error 形态对象的堆栈
166
+ * @tip 跨 realm Error 实例不是 instanceof Error,其 message/stack 属性原样可取
167
+ */
168
+ extractStack(reason) {
169
+ if (reason !== null && typeof reason === 'object') {
170
+ try {
171
+ const { stack } = reason;
172
+ if (typeof stack === 'string') {
173
+ return stack;
174
+ }
175
+ }
176
+ catch (e) {
177
+ // stack 读取器抛错,保持 undefined
178
+ }
179
+ }
180
+ return undefined;
181
+ }
182
+ /**
183
+ * 处理原生错误
184
+ */
185
+ handleNativeError(name, line, msg, stack) {
186
+ this._stats.Total++;
187
+ this._stats.Native++;
188
+ if (typeof this._nativeErrorHandler === 'function') {
189
+ this._nativeErrorHandler(name, line, msg, stack);
190
+ }
191
+ const error = {
192
+ Type: SentryType.NATIVE,
193
+ Code: 'NATIVE_ERROR',
194
+ Message: msg,
195
+ Filename: name,
196
+ Lineno: line,
197
+ Timestamp: Now(),
198
+ Stack: stack,
199
+ };
200
+ this.sendToReporter(error);
201
+ }
202
+ /**
203
+ * 报告给记者
204
+ */
205
+ sendToReporter(error) {
206
+ this.Logger.Error('捕获到异常', error);
207
+ // 开发、测试环境不上报(与 @reporter.Collect 的 IsDev||IsAlpha 守卫对齐,双层防御)
208
+ if (this.Deps.Launcher.IsDev || this.Deps.Launcher.IsAlpha)
209
+ return;
210
+ this.Deps.Reporter.Collect(ReporterType.ERROR, error.Code, error.Message, Object.assign({}, error));
211
+ }
212
+ get Stats() {
213
+ return Object.assign({}, this._stats);
214
+ }
215
+ }
216
+
217
+ export { SentryPlugin, SentryType };
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@nebulaaa/sentry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "哨兵(异常监控)插件",
5
+ "main": "./dist/sentry.cjs",
6
+ "module": "./dist/sentry.mjs",
7
+ "types": "./dist/sentry.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/sentry.d.ts",
11
+ "import": "./dist/sentry.mjs",
12
+ "require": "./dist/sentry.cjs",
13
+ "default": "./dist/sentry.cjs"
14
+ },
15
+ "./min": {
16
+ "types": "./dist/sentry.d.ts",
17
+ "import": "./dist/sentry.min.mjs",
18
+ "require": "./dist/sentry.min.cjs",
19
+ "default": "./dist/sentry.min.cjs"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
27
+ "build": "npm run clean && rollup -c rollup.config.mjs && node ../scripts/write-dist-package.mjs sentry",
28
+ "test": "jest --config jest.config.mjs --passWithNoTests"
29
+ },
30
+ "keywords": [
31
+ "sentry",
32
+ "nebulaaa"
33
+ ],
34
+ "author": "doooreyn",
35
+ "license": "MIT",
36
+ "devEngines": {
37
+ "packageManager": {
38
+ "name": "npm",
39
+ "version": ">=10.0.0",
40
+ "onFail": "warn"
41
+ }
42
+ },
43
+ "engines": {
44
+ "node": ">=16.0.0",
45
+ "npm": ">=10.0.0"
46
+ },
47
+ "publishConfig": {
48
+ "registry": "https://registry.npmjs.org/",
49
+ "access": "public"
50
+ },
51
+ "allowScripts": {
52
+ "unrs-resolver@1.12.2": true,
53
+ "@parcel/watcher@2.6.0": true
54
+ },
55
+ "devDependencies": {
56
+ "@cocos/creator-types": "^3.8.8",
57
+ "@rollup/plugin-terser": "^1.0.2",
58
+ "@rollup/plugin-typescript": "^12.1.2",
59
+ "@types/jest": "^30.0.0",
60
+ "@types/node": "^22.10.2",
61
+ "jest": "^30.5.2",
62
+ "rollup": "^4.28.1",
63
+ "rollup-plugin-dts": "^6.1.1",
64
+ "ts-jest": "^29.1.2",
65
+ "ts-node": "^10.9.2",
66
+ "typescript": "5.7.3"
67
+ },
68
+ "dependencies": {
69
+ "@nebulaaa/access": "^2.0.4",
70
+ "@nebulaaa/launcher": "^2.0.1",
71
+ "@nebulaaa/ms": "^1.1.0",
72
+ "@nebulaaa/plugin": "^1.0.0",
73
+ "@nebulaaa/reporter": "^1.0.2"
74
+ }
75
+ }