diff --git a/dist/core/Client.d.ts b/dist/core/Client.d.ts index 69e9164..35f9bd3 100644 --- a/dist/core/Client.d.ts +++ b/dist/core/Client.d.ts @@ -1,6 +1,5 @@ -import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb } from '../types'; +import type { LightConfig, LightClient, LightPlugin, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb } from '../types'; declare class Client implements LightClient { - config: LightConfig; dsn: DSNInfo; private configManager; private eventBus; @@ -8,8 +7,9 @@ declare class Client implements LightClient { private reporter; private pluginManager; private breadcrumbs; - private maxBreadcrumbs; private enabled; + private get maxBreadcrumbs(); + get config(): LightConfig; constructor(config: LightConfig); init(): void; private flushEvents; @@ -23,7 +23,6 @@ declare class Client implements LightClient { type: string; }): void; private buildErrorEvent; - private parseStackTrace; setUser(user: UserInfo | null): void; setTag(key: string, value: string): void; setTags(tags: Record): void; @@ -32,7 +31,7 @@ declare class Client implements LightClient { flush(): Promise; disable(): void; enable(): void; - use(plugin: unknown): void; + use(plugin: LightPlugin): void; destroy(): void; } export default Client; diff --git a/dist/core/ConfigManager.d.ts b/dist/core/ConfigManager.d.ts index 812becc..eac58d8 100644 --- a/dist/core/ConfigManager.d.ts +++ b/dist/core/ConfigManager.d.ts @@ -13,7 +13,6 @@ export declare class ConfigManager { setTags(tags: Record): void; setTag(key: string, value: string): void; setExtra(key: string, value: unknown): void; - shouldSample(): boolean; isIgnoredError(message: string): boolean; /** * 获取编码后的环境信息(延迟初始化,只在首次上报时计算) @@ -28,6 +27,7 @@ export declare class ConfigManager { applyToEvent(event: SentryEvent, includeFullContext?: boolean): SentryEvent; /** * 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs + * 注意:此方法不修改原始 event 对象,返回裁剪后的副本 */ private applyContextLevel; } diff --git a/dist/core/EventQueue.d.ts b/dist/core/EventQueue.d.ts index 40edad0..867fcf6 100644 --- a/dist/core/EventQueue.d.ts +++ b/dist/core/EventQueue.d.ts @@ -9,6 +9,10 @@ export declare class EventQueue { private flushCallback; private syncFlushCallback; private lastFlushTime; + private isFlushing; + private onVisibilityChange?; + private onBeforeUnload?; + private onPagehide?; private dedupeMap; private pendingCounts; private errorRateWindow; @@ -20,6 +24,7 @@ export declare class EventQueue { private setupVisibilityListener; private checkInfiniteLoop; enqueue(event: SentryEvent): void; + private cleanupExpiredDedupeEntries; private buildCountEvents; private getDedupeKey; flush(): Promise; diff --git a/dist/core/Reporter.d.ts b/dist/core/Reporter.d.ts index 9d91cd9..2a91c3a 100644 --- a/dist/core/Reporter.d.ts +++ b/dist/core/Reporter.d.ts @@ -35,7 +35,6 @@ export declare class Reporter { */ private stripSharedFields; private getEnvelopeType; - private generateEventId; private send; private sendSync; private sendViaImage; diff --git a/dist/index.cjs.js b/dist/index.cjs.js index 801442c..f744556 100644 --- a/dist/index.cjs.js +++ b/dist/index.cjs.js @@ -1,4 +1,3 @@ -"use strict";var T=Object.defineProperty;var le=Object.getOwnPropertyDescriptor;var pe=Object.getOwnPropertyNames;var de=Object.prototype.hasOwnProperty;var fe=(s,e)=>{for(var t in e)T(s,t,{get:e[t],enumerable:!0})},he=(s,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of pe(e))!de.call(s,r)&&r!==t&&T(s,r,{get:()=>e[r],enumerable:!(n=le(e,r))||n.enumerable});return s};var ge=s=>he(T({},"__esModule",{value:!0}),s);var Re={};fe(Re,{BehaviorPlugin:()=>$,Client:()=>B,ErrorPlugin:()=>O,NetworkPlugin:()=>U,OfflinePlugin:()=>K,PerformancePlugin:()=>F,addBreadcrumb:()=>oe,captureEvent:()=>te,captureException:()=>Y,captureMessage:()=>ee,default:()=>xe,disable:()=>ce,enable:()=>ue,flush:()=>ae,getClient:()=>Z,init:()=>J,setExtra:()=>se,setTag:()=>re,setTags:()=>ie,setUser:()=>ne});module.exports=ge(Re);function A(s){try{let e=s.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function b(s){return`${s.protocol}://${s.host}/${s.projectId}/envelope/`}function p(){return Date.now()}function v(s){return s!==null&&typeof s=="object"&&!Array.isArray(s)}function C(s){let e=0;for(let t=0;t`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return C(`${s}:${r}:${n}`)}function me(s){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=s.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function ve(s){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=s.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function ye(s){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(s),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(s),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",o=s.match(/iPhone/i),a=s.match(/iPad/i),c=s.match(/SM-([A-Z0-9]+)/i),u=s.match(/Pixel ([0-9A-Z]+)/i),d=s.match(/HUAWEI ([A-Z0-9]+)/i);return o?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):d&&(r="Huawei",i=d[1]),{type:n,vendor:r,model:i}}function X(){let s=typeof navigator!="undefined"?navigator.userAgent:"",e=me(s),t=ve(s),n=ye(s);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:s,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function g(){return typeof location!="undefined"?location.href:""}function z(){return typeof document!="undefined"?document.referrer:""}function M(s){if(!s)return s;try{let e=new URL(s),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let o=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>o.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}return r?e.toString():s}catch(e){return s}}function w(s,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(s)}return s.includes(t)}return t.test(s)})}var be={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},Ee={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function I(){let s=X(),e=be[s.browser.name]||s.browser.name.toLowerCase(),t=Ee[s.os.name]||s.os.name.toLowerCase();return{browser:{name:e,version:s.browser.major},os:{name:t,version:s.os.version.split(".")[0]},device:{type:s.device.type,brand:s.device.vendor||void 0,model:s.device.model||void 0}}}function V(){let s=X(),e=`${s.browser.name}|${s.browser.major}|${s.os.name}|${s.os.version.split(".")[0]}|${s.device.type}|${s.device.vendor}|${s.device.model}|${s.language}`;return C(e).substring(0,16)}var we={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},S=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...we,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()typeof n=="string"?e.includes(n):n.test(e))}getEncodedContext(){return this.contexts||(this.contexts=I()),this.contexts}applyToEvent(e,t=!0){var n,r,i,o;return this.config.release&&(e.release=this.config.release),this.config.environment&&(e.environment=this.config.environment),this.config.user&&(e.user=this.config.user),this.config.tags&&(e.tags={...this.config.tags,...e.tags||{}}),e.request?e.request.url||(e.request.url=g()):e.request={url:g(),referrer:z()},this.contextId||(this.contextId=V()),e.context_id=this.contextId,this.applyContextLevel(e),t&&!this.contextReported&&(this.contexts||(this.contexts=I()),e.contexts=this.contexts,e.env={b:(n=this.contexts.browser)==null?void 0:n.name,bv:(r=this.contexts.browser)==null?void 0:r.version,os:(i=this.contexts.os)==null?void 0:i.name,osv:(o=this.contexts.os)==null?void 0:o.version}),e}applyContextLevel(e){var i,o,a,c;let t=this.config.contextLevel;if(!t)return;let n=e.level||"error",r=t[n]||t.error;(o=(i=e.exception)==null?void 0:i.stacktrace)!=null&&o.frames&&r.maxStackFrames>0?e.exception.stacktrace.frames=e.exception.stacktrace.frames.slice(0,r.maxStackFrames):r.maxStackFrames===0&&((c=(a=e.exception)==null?void 0:a.stacktrace)!=null&&c.frames)&&delete e.exception.stacktrace,e.breadcrumbs&&r.maxBreadcrumbs>0?e.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs):r.maxBreadcrumbs===0&&delete e.breadcrumbs}};var x=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var R=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){typeof document!="undefined"&&document.addEventListener("visibilitychange",()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),typeof window!="undefined"&&(window.addEventListener("beforeunload",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),window.addEventListener("pagehide",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}))}checkInfiniteLoop(e){let t=p(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=p(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.length>=this.maxSize&&this.flush(),this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:p(),level:"info"};e.push(r)}return this.pendingCounts.clear(),e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{this.syncFlushCallback(n),this.eventBus.emit("reported",n)}catch(r){console.error("[LightSDK] Sync flush failed",r)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var Q={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},Ne=Object.fromEntries(Object.entries(Q).map(([s,e])=>[e,s]));function G(s){if(!s||typeof s!="object")return s;let e={};for(let[t,n]of Object.entries(s)){let r=Q[t]||t;e[r]=n}return e}var L=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;await this.delay(this.retryDelay*Math.pow(2,n-1))}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:this.generateEventId(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),o=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=G(c));let u=JSON.stringify(c),d=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});o.push(d,u)}return o.join(` -`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;io.release===t.release)&&(n.release=t.release),t.environment&&e.every(o=>o.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(o=>JSON.stringify(o.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}generateEventId(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}async send(e,t=!1){let n=b(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0});if(r.ok)return;let i=new Error(`HTTP ${r.status}`);throw i.status=r.status,i}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let o=new XMLHttpRequest;o.open("POST",n,!t),o.setRequestHeader("Content-Type","application/x-sentry-envelope"),o.onload=()=>{if(o.status>=200&&o.status<300)r();else{let a=new Error(`HTTP ${o.status}`);a.status=o.status,i(a)}},o.onerror=()=>i(new Error("Network error")),o.send(e)});throw new Error("No transport available")}sendSync(e){let t=b(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=b(this.dsn),n=new Image,r=encodeURIComponent(btoa(e));return n.src=t+"&sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var k=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var _=class{constructor(e){this.breadcrumbs=[];this.maxBreadcrumbs=20;this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=A(e.dsn),this.configManager=new S(e),this.config=this.configManager.getAll(),this.eventBus=new x,this.reporter=new L(this.dsn,(t=e.maxRetries)!=null?t:3,(n=e.retryDelay)!=null?n:1e3),this.pluginManager=new k(this),this.queue=new R((r=e.maxQueueSize)!=null?r:100,(i=e.flushInterval)!=null?i:5e3,this.eventBus,async o=>this.flushEvents(o),o=>this.syncFlushEvents(o)),this.enabled=e.enabled!==!1}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let o=this.pluginManager.applyBeforeReport(i);o&&t.push(o)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let o=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(o):o;a&&r.push(a)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let o=this.pluginManager.applyBeforeReport(i);o&&t.push(o)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let o=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(o):o;a&&r.push(a)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled||!this.configManager.shouldSample())return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled||!this.configManager.shouldSample())return;let n={type:"error",level:t,message:e,timestamp:p(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled||!this.configManager.shouldSample())return;let t={timestamp:p(),level:"info",...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=p();if(e instanceof Error){let r=this.parseStackTrace(e.stack),i=E(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r.slice(0,5)}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`);for(let r of n){let i=r.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(i){let[,o,a,c,u]=i;t.push({filename:a,function:o,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}else{let o=r.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(o){let[,a,c,u]=o;t.push({filename:a,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}}}return t.reverse()}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:p()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){e&&typeof e=="object"&&"name"in e&&"setup"in e&&this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},B=_;var D=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&v(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnoreError(e){return!this.config.ignoreErrors||!e?!1:this.config.ignoreErrors.some(t=>typeof t=="string"?e.includes(t):t.test(e))}shouldIgnoreScriptUrl(e){return w(e,this.config.ignoreUrls)}setupGlobalError(){if(typeof window=="undefined")return;let e=window.onerror;window.onerror=(t,n,r,i,o)=>{try{e&&e.call(window,t,n,r,i,o);let a=this.buildErrorEvent(o||t,n,r,i);a&&this.client.captureEvent(a)}catch(a){console.error("[LightSDK] ErrorPlugin global error handler error:",a)}return!1}}setupUnhandledRejection(){typeof window!="undefined"&&window.addEventListener("unhandledrejection",e=>{try{let t=e.reason,n=this.buildErrorEvent(t);n&&(n.type==="error"&&(n.tags={...n.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(n))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}})}setupResourceError(){typeof window!="undefined"&&window.addEventListener("error",e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${r}`,timestamp:p(),tags:{resource_type:n,resource_url:r},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(o){console.error("[LightSDK] ErrorPlugin resource error handler error:",o)}},!0)}buildErrorEvent(e,t,n,r){let i=p();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=this.parseStackTrace(e.stack),u=E(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c.slice(0,this.config.maxStackFrames||5)}},fingerprint:u}}let o=String(e);if(this.shouldIgnoreError(o))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),{type:"error",level:"error",message:o,timestamp:i,exception:{type:"Error",value:o,stacktrace:{frames:a}}}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`),r=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let i of n){let o=i.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(o){let[,a,c,u,d]=o,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,function:a,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}else{let a=i.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(a){let[,c,u,d]=a,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}}}return t.reverse()}destroy(){}},O=D;var N=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0}}setup(e){this.client=e,this.loadConfig(),this.shouldSample("default")&&(this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources())}loadConfig(){let e=this.client.config;e&&v(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"&&(t=(i=this.config.longTaskSampleRate)!=null?i:.05),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeLCP(){try{new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms")}}).observe({type:"largest-contentful-paint",buffered:!0})}catch(e){}}observeFID(){try{new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms")}}).observe({type:"first-input",buffered:!0})}catch(e){}}observeCLS(){try{let e=0,t=0,n=[];new PerformanceObserver(i=>{let o=i.getEntries();for(let a of o)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-u.endTime<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,""))}}).observe({type:"layout-shift",buffered:!0})}catch(e){}}observeFCP(){try{new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&this.reportMetric("FCP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeTTFB(){try{new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms")}}).observe({type:"navigation",buffered:!0})}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n)this.shouldSample("longtask")&&this.reportMetric("longtask",r.duration,"ms")}).observe({type:"longtask",buffered:!0})}catch(e){}}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&window.addEventListener("load",()=>{setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms")},0)})}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{new PerformanceObserver(t=>{if(!this.shouldSample("resource"))return;let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType})}).observe({type:"resource",buffered:!0})}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={}){let i=this.getRating(e,t),o={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:i,timestamp:p(),tags:{metric:e,rating:i,...r}};this.client.captureEvent(o)}destroy(){}},F=N;var q=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.patchFetch(),this.patchXHR()}loadConfig(){let e=this.client.config;e&&v(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return w(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,o,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((o=this.config.slowThreshold)!=null?o:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var y;let d=p()-r,f=u.ok,h=u.status;return(y=e.config.ignoreStatusCodes)!=null&&y.includes(h)||!e.shouldSample(!f,d)||(!f||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:o.toUpperCase(),url:a,status_code:h,duration:d,success:f,error:f?void 0:`HTTP ${h}`}),u},u=>{let d=p()-r;throw e.shouldSample(!0,d)&&e.reportNetwork({sub_type:"fetch",method:o.toUpperCase(),url:a,duration:d,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=p(),r=this._lightMethod||"GET",i=this._lightUrl||"";if(e.shouldIgnore(i))return e.originalXHRSend.apply(this,arguments);let o=M(i),a=0;t&&typeof t=="string"&&(a=t.length);let c=()=>{var y;let u=p()-n,d=this.status,f=d>=200&&d<300;if((y=e.config.ignoreStatusCodes)!=null&&y.includes(d)){this.removeEventListener("loadend",c);return}if(!e.shouldSample(!f,u)){this.removeEventListener("loadend",c);return}if(f&&!e.config.captureSuccess){this.removeEventListener("loadend",c);return}let h=0;try{let P=this.getResponseHeader("content-length");P&&(h=parseInt(P,10)),!h&&this.responseText&&(h=this.responseText.length)}catch(P){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:o,status_code:d,duration:u,request_size:a||void 0,response_size:h||void 0,success:f,error:f?void 0:`HTTP ${d}`}),this.removeEventListener("loadend",c)};return this.addEventListener("loadend",c),e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:p(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},U=q;var H=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.1};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.shouldSample()&&(this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll())}loadConfig(){let e=this.client.config;e&&v(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.1;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=p(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let i=g();if(i!==e){let o=e,a=i;e=i,this.reportBehavior({sub_type:"route",page_url:a,referrer:o,properties:{from:o,to:a}}),this.pageEnterTime=p(),this.maxScrollDepth=0,this.scrollReported=!1}},n=history.pushState,r=history.replaceState;history.pushState=function(){let i=n.apply(this,arguments);return setTimeout(t,0),i},history.replaceState=function(){let i=r.apply(this,arguments);return setTimeout(t,0),i},window.addEventListener("popstate",()=>{setTimeout(t,0)}),window.addEventListener("hashchange",()=>{setTimeout(t,0)})}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=p();let e=()=>{let t=p()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:g(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};document.addEventListener("visibilitychange",()=>{document.hidden?e():this.pageEnterTime=p()}),window.addEventListener("beforeunload",e),window.addEventListener("pagehide",e)}trackScroll(){if(typeof window=="undefined")return;let e=()=>{var i;let t=p(),n=(i=this.config.scrollThrottle)!=null?i:1e3;if(t-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=r)};window.addEventListener("scroll",e,{passive:!0})}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:g(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){let t={type:"behavior",level:"info",timestamp:p(),tags:{sub_type:e.sub_type,page_url:g()},...e};this.client.captureEvent(t)}destroy(){}},$=H;var Se="light-sentry-offline",m="events",W=1e3,j=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:W};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(Se,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,window.addEventListener("online",()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()}),window.addEventListener("offline",()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1}))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(this.db)return new Promise((t,n)=>{let i=this.db.transaction([m],"readwrite").objectStore(m),o=i.count();o.onsuccess=async()=>{let a=o.result;a>=(this.config.maxEvents||W)&&await this.deleteOldestEvents(a-(this.config.maxEvents||W)+1);let c={id:this.generateId(),event:{...e,offline:!0},timestamp:p()},u=i.add(c);u.onsuccess=()=>t(),u.onerror=()=>n(u.error)}})}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let d=u.target.result;d&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let o=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();o.onsuccess=()=>{let a=o.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},o.onerror=()=>t(o.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=await this.getAllStoredEvents();if(e.length===0){console.info("[LightSDK] OfflinePlugin: No pending events to sync"),this.isSyncing=!1;return}let t=50;for(let n=0;no.event);try{await(async()=>new Promise((a,c)=>{this.client.emit("sync:offline",i),a()}))(),console.info(`[LightSDK] OfflinePlugin: Synced ${r.length} events`)}catch(o){console.error("[LightSDK] OfflinePlugin: Failed to sync batch",o)}}await this.clearStoredEvents(),console.info("[LightSDK] OfflinePlugin: All pending events synced")}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline)return this.saveToIndexedDB(e).catch(n=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",n)}),null;let t={...e};return delete t.offline,t}destroy(){this.db&&(this.db.close(),this.db=null)}},K=j;var l=null;function J(s){var r;let e=new B(s),t=[new O,new K],n=((r=s.plugins)==null?void 0:r.filter(i=>typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new F),(n.includes("network")||n.includes("all"))&&t.push(new U),(n.includes("behavior")||n.includes("all"))&&t.push(new $);for(let i of t)e.use(i);if(s.plugins)for(let i of s.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function Z(){return l}function Y(s){l==null||l.captureException(s)}function ee(s,e){l==null||l.captureMessage(s,e)}function te(s){l==null||l.captureEvent(s)}function ne(s){l==null||l.setUser(s)}function re(s,e){l==null||l.setTag(s,e)}function ie(s){l==null||l.setTags(s)}function se(s,e){l==null||l.setExtra(s,e)}function oe(s){l==null||l.addBreadcrumb(s)}async function ae(){await(l==null?void 0:l.flush())}function ce(){l==null||l.disable()}function ue(){l==null||l.enable()}var xe={init:J,getClient:Z,captureException:Y,captureMessage:ee,captureEvent:te,setUser:ne,setTag:re,setTags:ie,setExtra:se,addBreadcrumb:oe,flush:ae,disable:ce,enable:ue}; +"use strict";var D=Object.defineProperty;var we=Object.getOwnPropertyDescriptor;var Ee=Object.getOwnPropertyNames;var Se=Object.prototype.hasOwnProperty;var xe=(o,e)=>{for(var t in e)D(o,t,{get:e[t],enumerable:!0})},Re=(o,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Ee(e))!Se.call(o,r)&&r!==t&&D(o,r,{get:()=>e[r],enumerable:!(n=we(e,r))||n.enumerable});return o};var ke=o=>Re(D({},"__esModule",{value:!0}),o);var De={};xe(De,{BehaviorPlugin:()=>V,Client:()=>q,ErrorPlugin:()=>j,NetworkPlugin:()=>A,OfflinePlugin:()=>G,PerformancePlugin:()=>W,SamplingPlugin:()=>Z,addBreadcrumb:()=>me,captureEvent:()=>de,captureException:()=>ue,captureMessage:()=>le,default:()=>Oe,disable:()=>ye,enable:()=>be,flush:()=>ve,getClient:()=>ce,init:()=>ae,setExtra:()=>ge,setTag:()=>he,setTags:()=>fe,setUser:()=>pe});module.exports=ke(De);function ee(o){try{let e=o.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function k(o){return`${o.protocol}://${o.host}/api/${o.projectId}/envelope/?sentry_key=${o.publicKey}`}function te(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}function d(){return Date.now()}function y(o){return o!==null&&typeof o=="object"&&!Array.isArray(o)}function T(o,e){return!e||e.length===0?!1:e.some(t=>typeof t=="string"?o.includes(t):t.test(o))}function Te(o){let e=5381,t=o.length;for(;t;)e=e*33^o.charCodeAt(--t);return e>>>0}function F(o){return Te(o).toString(16)}function L(o,e,t=[]){let n=t.slice(0,3).map(i=>`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return F(`${o}:${r}:${n}`)}function P(o,e={}){if(!o)return[];let{captureNodeModules:t=!1,captureColumn:n=!0,relativePathOnly:r=!1,maxFrames:i}=e,s=[],a=o.split(` +`),c=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let p of a){let w=p.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(w){let[,f,h,v,g]=w,E=h.includes("node_modules");if(!t&&E)continue;let S=h;r&&c&&h.startsWith(c)&&(S=h.substring(c.length)),s.push({filename:S,function:f,lineno:parseInt(v,10),colno:n?parseInt(g,10):void 0,in_app:!E})}else{let f=p.match(/^\s*at\s*(.+?)@(.+?):(\d+):(\d+)\s*$/);if(f){let[,v,g,E,S]=f,R=g.includes("node_modules");if(!t&&R)continue;let Y=g;r&&c&&g.startsWith(c)&&(Y=g.substring(c.length)),s.push({filename:Y,function:v||"",lineno:parseInt(E,10),colno:n?parseInt(S,10):void 0,in_app:!R});continue}let h=p.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(h){let[,v,g,E]=h,S=v.includes("node_modules");if(!t&&S)continue;let R=v;r&&c&&v.startsWith(c)&&(R=v.substring(c.length)),s.push({filename:R,lineno:parseInt(g,10),colno:n?parseInt(E,10):void 0,in_app:!S})}}}let u=s.reverse();return i!==void 0&&i>0?u.slice(0,i):u}function Le(o){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function Pe(o){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function Ce(o){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(o),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(o),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",s=o.match(/iPhone/i),a=o.match(/iPad/i),c=o.match(/SM-([A-Z0-9]+)/i),u=o.match(/Pixel ([0-9A-Z]+)/i),p=o.match(/HUAWEI ([A-Z0-9]+)/i);return s?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):p&&(r="Huawei",i=p[1]),{type:n,vendor:r,model:i}}function ne(){let o=typeof navigator!="undefined"?navigator.userAgent:"",e=Le(o),t=Pe(o),n=Ce(o);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:o,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function b(){return typeof location!="undefined"?location.href:""}function re(){return typeof document!="undefined"?document.referrer:""}function x(o){if(!o)return o;try{let e=new URL(o),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let s=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>s.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}if(e.hash&&e.hash.length>1){let i=e.hash.substring(1);if(i.includes("=")){let s=new URLSearchParams(i),a=!1;for(let c of Array.from(s.keys())){let u=c.toLowerCase().replace(/[-_]/g,"_");t.some(p=>u.includes(p))&&(s.set(c,"[Filtered]"),a=!0)}a&&(e.hash="#"+s.toString(),r=!0)}}return r?e.toString():o}catch(e){return o}}function C(o,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(o)}return o.includes(t)}return t.test(o)})}var Me={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},Be={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function U(){let o=ne(),e=Me[o.browser.name]||o.browser.name.toLowerCase(),t=Be[o.os.name]||o.os.name.toLowerCase();return{browser:{name:e,version:o.browser.major},os:{name:t,version:o.os.version.split(".")[0]},device:{type:o.device.type,brand:o.device.vendor||void 0,model:o.device.model||void 0}}}function ie(){let o=ne(),e=`${o.browser.name}|${o.browser.major}|${o.os.name}|${o.os.version.split(".")[0]}|${o.device.type}|${o.device.vendor}|${o.device.model}|${o.language}`;return F(e).substring(0,16)}var Ie={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},M=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...Ie,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}isIgnoredError(e){return T(e,this.config.ignoreErrors||[])}getEncodedContext(){return this.contexts||(this.contexts=U()),this.contexts}applyToEvent(e,t=!0){var i,s,a,c;let n={...e};if(e.tags&&(n.tags={...e.tags}),e.request&&(n.request={...e.request}),"exception"in e&&e.exception){let u=e.exception;n.exception={...u,stacktrace:u.stacktrace?{...u.stacktrace,frames:u.stacktrace.frames?u.stacktrace.frames.map(p=>({...p})):void 0}:void 0}}e.breadcrumbs&&(n.breadcrumbs=e.breadcrumbs.map(u=>({...u}))),e.user&&(n.user={...e.user}),e.contexts&&(n.contexts={...e.contexts}),this.config.release&&(n.release=this.config.release),this.config.environment&&(n.environment=this.config.environment),this.config.user&&(n.user=this.config.user),this.config.tags&&(n.tags={...this.config.tags,...e.tags||{}}),n.request?n.request.url||(n.request.url=b()):n.request={url:b(),referrer:re()},this.contextId||(this.contextId=ie()),n.context_id=this.contextId;let r=this.applyContextLevel(n);return t&&!this.contextReported&&(this.contexts||(this.contexts=U()),r.contexts=this.contexts,r.env={b:(i=this.contexts.browser)==null?void 0:i.name,bv:(s=this.contexts.browser)==null?void 0:s.version,os:(a=this.contexts.os)==null?void 0:a.name,osv:(c=this.contexts.os)==null?void 0:c.version}),r}applyContextLevel(e){let t=this.config.contextLevel;if(!t)return e;let n=e.level||"error",r=t[n]||t.error,i={...e};if("exception"in e&&e.exception&&typeof e.exception=="object"){let s=e.exception;if(s.stacktrace&&typeof s.stacktrace=="object"){let a=s.stacktrace;Array.isArray(a.frames)&&(r.maxStackFrames===0?delete i.exception:i.exception={...s,stacktrace:{...a,frames:a.frames.slice(0,r.maxStackFrames)}})}}return e.breadcrumbs&&Array.isArray(e.breadcrumbs)&&(r.maxBreadcrumbs===0?delete i.breadcrumbs:i.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs)),i}};var B=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var I=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.isFlushing=!1;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){this.onVisibilityChange=()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof document!="undefined"&&document.addEventListener("visibilitychange",this.onVisibilityChange),this.onBeforeUnload=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},this.onPagehide=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof window!="undefined"&&(window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide))}checkInfiniteLoop(e){let t=d(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;this.cleanupExpiredDedupeEntries();let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=d(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}cleanupExpiredDedupeEntries(){if(this.dedupeMap.size<100&&Math.random()>.1)return;let e=d(),t=6e4;for(let[n,r]of this.dedupeMap)e-r.lastTime>t&&this.dedupeMap.delete(n)}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:d(),level:"info"};e.push(r)}return e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0||this.isFlushing)return;this.isFlushing=!0;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=d(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.pendingCounts.clear(),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}finally{this.isFlushing=!1}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=new Map(this.pendingCounts),n=this.buildCountEvents(),r=[...e,...n];this.lastFlushTime=d(),this.eventBus.emit("report",r);try{this.syncFlushCallback(r),this.pendingCounts.clear(),this.eventBus.emit("reported",r)}catch(i){console.error("[LightSDK] Sync flush failed",i),e.forEach(s=>this.queue.unshift(s));for(let[s,a]of t)this.pendingCounts.has(s)||this.pendingCounts.set(s,a)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),typeof document!="undefined"&&this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange),typeof window!="undefined"&&(this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide)),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var se={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},Qe=Object.fromEntries(Object.entries(se).map(([o,e])=>[e,o]));function oe(o){if(!o||typeof o!="object")return o;let e={};for(let[t,n]of Object.entries(o)){let r=se[t]||t;e[r]=n}return e}var _=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;let s=Math.min(this.retryDelay*Math.pow(2,n-1),5e3);await this.delay(s)}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:te(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),s=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=oe(c));let u=JSON.stringify(c),p=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});s.push(p,u)}return s.join(` +`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;is.release===t.release)&&(n.release=t.release),t.environment&&e.every(s=>s.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(s=>JSON.stringify(s.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}async send(e,t=!1){let n=k(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=new AbortController,i=setTimeout(()=>r.abort(),1e4),s=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0,signal:r.signal});if(clearTimeout(i),s.ok)return;let a=new Error(`HTTP ${s.status}`);throw a.status=s.status,a}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let s=new XMLHttpRequest;s.open("POST",n,!t),s.setRequestHeader("Content-Type","application/x-sentry-envelope"),s.timeout=1e4,s.onload=()=>{if(s.status>=200&&s.status<300)r();else{let a=new Error(`HTTP ${s.status}`);a.status=s.status,i(a)}},s.onerror=()=>i(new Error("Network error")),s.ontimeout=()=>i(new Error("Request timeout")),s.send(e)});throw new Error("No transport available")}sendSync(e){let t=k(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=k(this.dsn),n=new Image,r=encodeURIComponent(btoa(e)),i=t.includes("?")?"&":"?";return n.src=t+i+"sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var O=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var N=class{constructor(e){this.breadcrumbs=[];this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=ee(e.dsn),this.configManager=new M(e),this.eventBus=new B,this.reporter=new _(this.dsn,(t=this.configManager.get("maxRetries"))!=null?t:3,(n=this.configManager.get("retryDelay"))!=null?n:1e3),this.pluginManager=new O(this),this.queue=new I((r=this.configManager.get("maxQueueSize"))!=null?r:100,(i=this.configManager.get("flushInterval"))!=null?i:5e3,this.eventBus,async s=>this.flushEvents(s),s=>this.syncFlushEvents(s)),this.enabled=this.configManager.get("enabled")!==!1}get maxBreadcrumbs(){var e;return(e=this.configManager.get("maxBreadcrumbs"))!=null?e:20}get config(){return this.configManager.getAll()}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled)return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled)return;let n={type:"error",level:t,message:e,timestamp:d(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled)return;let t={timestamp:d(),level:"info",breadcrumbs:[...this.breadcrumbs],...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=d();if(e instanceof Error){let r=P(e.stack,{maxFrames:5}),i=L(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:d()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},q=N;var H=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1,resourceSampleRate:.1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&y(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls]),e.includePaths&&(this.config.includePaths=[...this.config.includePaths||[],...e.includePaths])}shouldIgnoreError(e){return e?T(e,this.config.ignoreErrors||[]):!1}shouldIgnoreScriptUrl(e){return C(e,this.config.ignoreUrls||[])}shouldIncludePath(e){let t=this.config.includePaths||[];return t.length===0?!0:e.some(n=>t.some(r=>{var i;return typeof r=="string"?(i=n.filename)==null?void 0:i.includes(r):r.test(n.filename||"")}))}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()=1?!0:e<=0?!1:Math.random(){try{if(this.originalOnError&&this.originalOnError.call(window,e,t,n,r,i),t&&this.shouldIgnoreScriptUrl(t))return!1;let s=this.buildErrorEvent(i||e,t,n,r);s&&this.client.captureEvent(s)}catch(s){console.error("[LightSDK] ErrorPlugin global error handler error:",s)}return!1})}setupUnhandledRejection(){typeof window!="undefined"&&(this.onUnhandledRejection=e=>{try{let t=e.reason,n=this.extractUrlFromReason(t);if(n&&this.shouldIgnoreScriptUrl(n))return;let r=this.buildErrorEvent(t);r&&(r.type==="error"&&(r.tags={...r.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(r))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}},window.addEventListener("unhandledrejection",this.onUnhandledRejection))}extractUrlFromReason(e){if(e instanceof Error&&e.stack){let t=e.stack.match(/at\s+(?:.+?\s+)?\((https?:\/\/[^:]+):\d+:\d+\)/);if(t)return t[1];let n=e.stack.match(/(https?:\/\/[^\s)]+):\d+:\d+/);if(n)return n[1]}return null}setupResourceError(){typeof window!="undefined"&&(this.onResourceError=e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{if(this.shouldIgnoreScriptUrl(r)||!this.shouldSampleResource())return;let s=`resource:${n}`,a=x(r);this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${a}`,timestamp:d(),fingerprint:s,tags:{resource_type:n,resource_url:a},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(s){console.error("[LightSDK] ErrorPlugin resource error handler error:",s)}},window.addEventListener("error",this.onResourceError,!0))}buildErrorEvent(e,t,n,r){let i=d();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=P(e.stack,{captureNodeModules:this.config.captureNodeModules,captureColumn:this.config.captureColumn,relativePathOnly:this.config.relativePathOnly,maxFrames:this.config.maxStackFrames});if(!this.shouldIncludePath(c)||!this.shouldSample())return null;let u=L(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c}},fingerprint:u}}let s=String(e);if(this.shouldIgnoreError(s))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),!this.shouldIncludePath(a)||!this.shouldSample()?null:{type:"error",level:"error",message:s,timestamp:i,exception:{type:"Error",value:s,stacktrace:{frames:a}}}}destroy(){typeof window!="undefined"&&(this.originalOnError!==void 0&&(window.onerror=this.originalOnError),this.onUnhandledRejection&&window.removeEventListener("unhandledrejection",this.onUnhandledRejection),this.onResourceError&&window.removeEventListener("error",this.onResourceError,!0))}},j=H;var $=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0};this.observers=[];this.navLoadTimer=null;this.totalBlockingTime=0;this.fcpTime=null;this.lastLongTaskEndTime=0;this.ttiReported=!1;this.ttiTimer=null}setup(e){this.client=e,this.loadConfig(),this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources()}loadConfig(){let e=this.client.config;e&&y(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"?t=(i=this.config.longTaskSampleRate)!=null?i:.05:e==="vitals"&&(t=1),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms",{},"vitals")});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeLCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms",{},"vitals")}});e.observe({type:"largest-contentful-paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeFID(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms",{},"vitals")}});e.observe({type:"first-input",buffered:!0}),this.observers.push(e)}catch(e){}}observeCLS(){try{let e=0,t=0,n=[],r=new PerformanceObserver(i=>{let s=i.getEntries();for(let a of s)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-((u==null?void 0:u.duration)||0)<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,"",{},"vitals"))}});r.observe({type:"layout-shift",buffered:!0}),this.observers.push(r)}catch(e){}}observeFCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&(this.fcpTime=n[0].startTime,this.lastLongTaskEndTime=n[0].startTime,this.reportMetric("FCP",n[0].startTime,"ms",{},"vitals"),this.scheduleTTI())});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeTTFB(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms",{},"vitals")}});e.observe({type:"navigation",buffered:!0}),this.observers.push(e)}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{let e=new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n){let i=r.startTime+r.duration;if(i>this.lastLongTaskEndTime&&(this.lastLongTaskEndTime=i),this.fcpTime!==null&&r.startTime>=this.fcpTime){let s=r.duration-50;s>0&&(this.totalBlockingTime+=s)}this.reportMetric("longtask",r.duration,"ms",{},"longtask"),this.scheduleTTI()}});e.observe({type:"longtask",buffered:!0}),this.observers.push(e)}catch(e){}}scheduleTTI(){this.ttiReported||(this.ttiTimer&&clearTimeout(this.ttiTimer),this.ttiTimer=setTimeout(()=>{if(this.ttiReported)return;this.ttiReported=!0;let e=Math.max(this.lastLongTaskEndTime,this.fcpTime||0);this.reportMetric("TTI",e,"ms",{},"vitals"),this.reportMetric("TBT",this.totalBlockingTime,"ms",{},"vitals")},5e3))}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&(this.onLoad=()=>{this.navLoadTimer=setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms");this.navLoadTimer=null},0)},window.addEventListener("load",this.onLoad))}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType},"resource")});e.observe({type:"resource",buffered:!0}),this.observers.push(e)}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={},i="default"){if(!this.shouldSample(i))return;let s=this.getRating(e,t),a={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:s,timestamp:d(),tags:{metric:e,rating:s,...r}};this.client.captureEvent(a)}destroy(){for(let e of this.observers)try{e.disconnect()}catch(t){}this.observers=[],this.ttiTimer&&(clearTimeout(this.ttiTimer),this.ttiTimer=null),this.navLoadTimer&&(clearTimeout(this.navLoadTimer),this.navLoadTimer=null),typeof window!="undefined"&&this.onLoad&&window.removeEventListener("load",this.onLoad)}},W=$;var X=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureXHR:!0,captureFetch:!0,captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.config.captureFetch&&this.patchFetch(),this.config.captureXHR&&this.patchXHR()}loadConfig(){let e=this.client.config;e&&y(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return C(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,s,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((s=this.config.slowThreshold)!=null?s:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var h;let p=d()-r,w=u.ok,f=u.status;return(h=e.config.ignoreStatusCodes)!=null&&h.includes(f)||!e.shouldSample(!w,p)||(!w||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,status_code:f,duration:p,success:w,error:w?void 0:`HTTP ${f}`}),u},u=>{let p=d()-r;throw e.shouldSample(!0,p)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,duration:p,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),this._lightTracked=!1,e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=d(),r=this._lightMethod||"GET",i=this._lightUrl||"",s=this._lightTracked;if(e.shouldIgnore(i)||s)return e.originalXHRSend.apply(this,arguments);this._lightTracked=!0;let a=x(i),c=0;t&&typeof t=="string"&&(c=t.length);let u=this.onloadend;return this.onloadend=function(p){var v;let w=d()-n,f=this.status,h=f>=200&&f<300;if(!((v=e.config.ignoreStatusCodes)!=null&&v.includes(f))&&e.shouldSample(!h,w)&&(!h||e.config.captureSuccess)){let g=0;try{let E=this.getResponseHeader("content-length");E&&(g=parseInt(E,10)),!g&&this.responseText&&(g=this.responseText.length)}catch(E){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:a,status_code:f,duration:w,request_size:c||void 0,response_size:g||void 0,success:h,error:h?void 0:`HTTP ${f}`})}if(u)return u.call(this,p)},e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:d(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},A=X;var K=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.01};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll()}loadConfig(){let e=this.client.config;e&&y(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.01;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=d(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let r=b();if(r!==e){let i=e,s=r;e=r,this.reportBehavior({sub_type:"route",page_url:s,referrer:i,properties:{from:i,to:s}}),this.pageEnterTime=d(),this.maxScrollDepth=0,this.scrollReported=!1}};this.originalPushState=history.pushState,this.originalReplaceState=history.replaceState;let n=this;history.pushState=function(){let r=n.originalPushState.apply(this,arguments);return setTimeout(t,0),r},history.replaceState=function(){let r=n.originalReplaceState.apply(this,arguments);return setTimeout(t,0),r},this.onPopState=()=>{setTimeout(t,0)},this.onHashchange=()=>{setTimeout(t,0)},window.addEventListener("popstate",this.onPopState),window.addEventListener("hashchange",this.onHashchange)}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=d();let e=()=>{let t=d()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:b(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};this.onVisibilityChange=()=>{document.hidden?e():this.pageEnterTime=d()},this.onBeforeUnload=e,this.onPagehide=e,document.addEventListener("visibilitychange",this.onVisibilityChange),window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide)}trackScroll(){typeof window!="undefined"&&(this.onScroll=()=>{var r;let e=d(),t=(r=this.config.scrollThrottle)!=null?r:1e3;if(e-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=n)},window.addEventListener("scroll",this.onScroll,{passive:!0}))}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:b(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){if(!this.shouldSample())return;let t={type:"behavior",level:"info",timestamp:d(),tags:{sub_type:e.sub_type,page_url:b()},...e};this.client.captureEvent(t)}destroy(){typeof window!="undefined"&&(this.originalPushState&&(history.pushState=this.originalPushState),this.originalReplaceState&&(history.replaceState=this.originalReplaceState),this.onPopState&&window.removeEventListener("popstate",this.onPopState),this.onHashchange&&window.removeEventListener("hashchange",this.onHashchange),this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide),this.onScroll&&window.removeEventListener("scroll",this.onScroll)),typeof document!="undefined"&&(this.onClick&&document.removeEventListener("click",this.onClick,!0),this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange))}},V=K;var _e="light-sentry-offline",m="events",z=1e3,Q=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:z};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(_e,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result,s=r.oldVersion;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,this.onOnline=()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()},this.onOffline=()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1},window.addEventListener("online",this.onOnline),window.addEventListener("offline",this.onOffline))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(!this.db)return;let t=this.config.maxEvents||z,n=await this.getEventCount();return n>=t&&await this.deleteOldestEvents(n-t+1),new Promise((r,i)=>{let a=this.db.transaction([m],"readwrite").objectStore(m),c={id:this.generateId(),event:{...e,offline:!0},timestamp:d()},u=a.add(c);u.onsuccess=()=>r(),u.onerror=()=>i(u.error)})}getEventCount(){return this.db?new Promise((e,t)=>{let i=this.db.transaction([m],"readonly").objectStore(m).count();i.onsuccess=()=>e(i.result),i.onerror=()=>t(i.error)}):Promise.resolve(0)}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let p=u.target.result;p&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let s=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();s.onsuccess=()=>{let a=s.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},s.onerror=()=>t(s.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=0;for(;this.pendingEvents.length>0;){let n=this.pendingEvents.shift();try{this.client.captureEvent(n),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue memory event",r)}}let t=await this.getAllStoredEvents();if(t.length>0)for(let n of t)try{let r=n.event;this.client.captureEvent(r),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue event",r)}try{await this.client.flush()}catch(n){}await this.clearStoredEvents(),console.info(`[LightSDK] OfflinePlugin: Synced ${e} events`)}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline){let n=this.config.maxEvents||z;return this.pendingEvents.length>=n&&this.pendingEvents.shift(),this.pendingEvents.push(e),this.savePendingEventsToDB(),null}let t={...e};return delete t.offline,t}savePendingEventsToDB(){if(this.pendingEvents.length===0)return;let e=this.pendingEvents.splice(0,this.pendingEvents.length),t=0,n=()=>{if(t>=e.length)return;let r=e[t];this.saveToIndexedDB(r).then(()=>{t++,n()}).catch(i=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",i),this.pendingEvents.unshift(...e.slice(t))})};n()}destroy(){typeof window!="undefined"&&(this.onOnline&&window.removeEventListener("online",this.onOnline),this.onOffline&&window.removeEventListener("offline",this.onOffline)),this.db&&(this.db.close(),this.db=null)}},G=Q;var J=class{constructor(){this.name="sampling";this.version="1.0.0";this.config={rates:{error:1,performance:1,network:1,behavior:1}}}setup(e){this.client=e,this.loadConfig()}loadConfig(){let e=this.client.config;if(e&&y(e.sampling)){let t=e.sampling;"rates"in t&&y(t.rates)&&(this.config.rates={...this.config.rates,...t.rates})}}shouldSample(e){var r;let t=(r=this.client.config.sampleRate)!=null?r:1;if(t<=0)return!1;let n=this.getTypeRate(e);return t>=1?this.randomCheck(n):this.randomCheck(t*n)}getTypeRate(e){let t=this.config.rates||{},n=e.type;if(n==="count")return 1;let r=t[n];return r!==void 0?r:1}randomCheck(e){return e>=1?!0:e<=0?!1:Math.random()typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new W),(n.includes("network")||n.includes("all"))&&t.push(new A),(n.includes("behavior")||n.includes("all"))&&t.push(new V);for(let i of t)e.use(i);if(o.plugins)for(let i of o.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function ce(){return l}function ue(o){l==null||l.captureException(o)}function le(o,e){l==null||l.captureMessage(o,e)}function de(o){l==null||l.captureEvent(o)}function pe(o){l==null||l.setUser(o)}function he(o,e){l==null||l.setTag(o,e)}function fe(o){l==null||l.setTags(o)}function ge(o,e){l==null||l.setExtra(o,e)}function me(o){l==null||l.addBreadcrumb(o)}async function ve(){await(l==null?void 0:l.flush())}function ye(){l==null||l.disable()}function be(){l==null||l.enable()}var Oe={init:ae,getClient:ce,captureException:ue,captureMessage:le,captureEvent:de,setUser:pe,setTag:he,setTags:fe,setExtra:ge,addBreadcrumb:me,flush:ve,disable:ye,enable:be}; diff --git a/dist/index.d.ts b/dist/index.d.ts index 86a8aff..1a85cc2 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -4,6 +4,7 @@ import PerformancePlugin from './plugins/PerformancePlugin'; import NetworkPlugin from './plugins/NetworkPlugin'; import BehaviorPlugin from './plugins/BehaviorPlugin'; import OfflinePlugin from './plugins/OfflinePlugin'; +import SamplingPlugin from './plugins/SamplingPlugin'; import type { LightConfig, LightClient, SentryEvent, EventLevel, UserInfo, Breadcrumb, LightPlugin, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, DSNInfo } from './types'; declare function init(config: LightConfig): LightClient; declare function getClient(): LightClient | null; @@ -20,7 +21,7 @@ declare function addBreadcrumb(breadcrumb: Omit): void; declare function flush(): Promise; declare function disable(): void; declare function enable(): void; -export { init, getClient, captureException, captureMessage, captureEvent, setUser, setTag, setTags, setExtra, addBreadcrumb, flush, disable, enable, Client, ErrorPlugin, PerformancePlugin, NetworkPlugin, BehaviorPlugin, OfflinePlugin, }; +export { init, getClient, captureException, captureMessage, captureEvent, setUser, setTag, setTags, setExtra, addBreadcrumb, flush, disable, enable, Client, ErrorPlugin, PerformancePlugin, NetworkPlugin, BehaviorPlugin, OfflinePlugin, SamplingPlugin, }; export type { LightConfig, LightClient, LightPlugin, SentryEvent, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, EventLevel, UserInfo, Breadcrumb, DSNInfo, }; declare const _default: { init: typeof init; diff --git a/dist/index.esm.js b/dist/index.esm.js index 2776006..b56b8c5 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -1,4 +1,3 @@ -function q(o){try{let e=o.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function b(o){return`${o.protocol}://${o.host}/${o.projectId}/envelope/`}function p(){return Date.now()}function v(o){return o!==null&&typeof o=="object"&&!Array.isArray(o)}function T(o){let e=0;for(let t=0;t`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return T(`${o}:${r}:${n}`)}function G(o){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function J(o){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function Z(o){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(o),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(o),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",s=o.match(/iPhone/i),a=o.match(/iPad/i),c=o.match(/SM-([A-Z0-9]+)/i),u=o.match(/Pixel ([0-9A-Z]+)/i),d=o.match(/HUAWEI ([A-Z0-9]+)/i);return s?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):d&&(r="Huawei",i=d[1]),{type:n,vendor:r,model:i}}function U(){let o=typeof navigator!="undefined"?navigator.userAgent:"",e=G(o),t=J(o),n=Z(o);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:o,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function g(){return typeof location!="undefined"?location.href:""}function H(){return typeof document!="undefined"?document.referrer:""}function C(o){if(!o)return o;try{let e=new URL(o),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let s=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>s.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}return r?e.toString():o}catch(e){return o}}function w(o,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(o)}return o.includes(t)}return t.test(o)})}var Y={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},ee={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function M(){let o=U(),e=Y[o.browser.name]||o.browser.name.toLowerCase(),t=ee[o.os.name]||o.os.name.toLowerCase();return{browser:{name:e,version:o.browser.major},os:{name:t,version:o.os.version.split(".")[0]},device:{type:o.device.type,brand:o.device.vendor||void 0,model:o.device.model||void 0}}}function $(){let o=U(),e=`${o.browser.name}|${o.browser.major}|${o.os.name}|${o.os.version.split(".")[0]}|${o.device.type}|${o.device.vendor}|${o.device.model}|${o.language}`;return T(e).substring(0,16)}var te={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},S=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...te,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()typeof n=="string"?e.includes(n):n.test(e))}getEncodedContext(){return this.contexts||(this.contexts=M()),this.contexts}applyToEvent(e,t=!0){var n,r,i,s;return this.config.release&&(e.release=this.config.release),this.config.environment&&(e.environment=this.config.environment),this.config.user&&(e.user=this.config.user),this.config.tags&&(e.tags={...this.config.tags,...e.tags||{}}),e.request?e.request.url||(e.request.url=g()):e.request={url:g(),referrer:H()},this.contextId||(this.contextId=$()),e.context_id=this.contextId,this.applyContextLevel(e),t&&!this.contextReported&&(this.contexts||(this.contexts=M()),e.contexts=this.contexts,e.env={b:(n=this.contexts.browser)==null?void 0:n.name,bv:(r=this.contexts.browser)==null?void 0:r.version,os:(i=this.contexts.os)==null?void 0:i.name,osv:(s=this.contexts.os)==null?void 0:s.version}),e}applyContextLevel(e){var i,s,a,c;let t=this.config.contextLevel;if(!t)return;let n=e.level||"error",r=t[n]||t.error;(s=(i=e.exception)==null?void 0:i.stacktrace)!=null&&s.frames&&r.maxStackFrames>0?e.exception.stacktrace.frames=e.exception.stacktrace.frames.slice(0,r.maxStackFrames):r.maxStackFrames===0&&((c=(a=e.exception)==null?void 0:a.stacktrace)!=null&&c.frames)&&delete e.exception.stacktrace,e.breadcrumbs&&r.maxBreadcrumbs>0?e.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs):r.maxBreadcrumbs===0&&delete e.breadcrumbs}};var x=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var R=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){typeof document!="undefined"&&document.addEventListener("visibilitychange",()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),typeof window!="undefined"&&(window.addEventListener("beforeunload",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),window.addEventListener("pagehide",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}))}checkInfiniteLoop(e){let t=p(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=p(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.length>=this.maxSize&&this.flush(),this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:p(),level:"info"};e.push(r)}return this.pendingCounts.clear(),e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{this.syncFlushCallback(n),this.eventBus.emit("reported",n)}catch(r){console.error("[LightSDK] Sync flush failed",r)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var W={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},ke=Object.fromEntries(Object.entries(W).map(([o,e])=>[e,o]));function j(o){if(!o||typeof o!="object")return o;let e={};for(let[t,n]of Object.entries(o)){let r=W[t]||t;e[r]=n}return e}var L=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;await this.delay(this.retryDelay*Math.pow(2,n-1))}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:this.generateEventId(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),s=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=j(c));let u=JSON.stringify(c),d=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});s.push(d,u)}return s.join(` -`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;is.release===t.release)&&(n.release=t.release),t.environment&&e.every(s=>s.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(s=>JSON.stringify(s.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}generateEventId(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}async send(e,t=!1){let n=b(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0});if(r.ok)return;let i=new Error(`HTTP ${r.status}`);throw i.status=r.status,i}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let s=new XMLHttpRequest;s.open("POST",n,!t),s.setRequestHeader("Content-Type","application/x-sentry-envelope"),s.onload=()=>{if(s.status>=200&&s.status<300)r();else{let a=new Error(`HTTP ${s.status}`);a.status=s.status,i(a)}},s.onerror=()=>i(new Error("Network error")),s.send(e)});throw new Error("No transport available")}sendSync(e){let t=b(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=b(this.dsn),n=new Image,r=encodeURIComponent(btoa(e));return n.src=t+"&sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var k=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var I=class{constructor(e){this.breadcrumbs=[];this.maxBreadcrumbs=20;this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=q(e.dsn),this.configManager=new S(e),this.config=this.configManager.getAll(),this.eventBus=new x,this.reporter=new L(this.dsn,(t=e.maxRetries)!=null?t:3,(n=e.retryDelay)!=null?n:1e3),this.pluginManager=new k(this),this.queue=new R((r=e.maxQueueSize)!=null?r:100,(i=e.flushInterval)!=null?i:5e3,this.eventBus,async s=>this.flushEvents(s),s=>this.syncFlushEvents(s)),this.enabled=e.enabled!==!1}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(s):s;a&&r.push(a)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(s):s;a&&r.push(a)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled||!this.configManager.shouldSample())return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled||!this.configManager.shouldSample())return;let n={type:"error",level:t,message:e,timestamp:p(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled||!this.configManager.shouldSample())return;let t={timestamp:p(),level:"info",...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=p();if(e instanceof Error){let r=this.parseStackTrace(e.stack),i=E(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r.slice(0,5)}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`);for(let r of n){let i=r.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(i){let[,s,a,c,u]=i;t.push({filename:a,function:s,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}else{let s=r.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(s){let[,a,c,u]=s;t.push({filename:a,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}}}return t.reverse()}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:p()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){e&&typeof e=="object"&&"name"in e&&"setup"in e&&this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},K=I;var _=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&v(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnoreError(e){return!this.config.ignoreErrors||!e?!1:this.config.ignoreErrors.some(t=>typeof t=="string"?e.includes(t):t.test(e))}shouldIgnoreScriptUrl(e){return w(e,this.config.ignoreUrls)}setupGlobalError(){if(typeof window=="undefined")return;let e=window.onerror;window.onerror=(t,n,r,i,s)=>{try{e&&e.call(window,t,n,r,i,s);let a=this.buildErrorEvent(s||t,n,r,i);a&&this.client.captureEvent(a)}catch(a){console.error("[LightSDK] ErrorPlugin global error handler error:",a)}return!1}}setupUnhandledRejection(){typeof window!="undefined"&&window.addEventListener("unhandledrejection",e=>{try{let t=e.reason,n=this.buildErrorEvent(t);n&&(n.type==="error"&&(n.tags={...n.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(n))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}})}setupResourceError(){typeof window!="undefined"&&window.addEventListener("error",e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${r}`,timestamp:p(),tags:{resource_type:n,resource_url:r},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(s){console.error("[LightSDK] ErrorPlugin resource error handler error:",s)}},!0)}buildErrorEvent(e,t,n,r){let i=p();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=this.parseStackTrace(e.stack),u=E(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c.slice(0,this.config.maxStackFrames||5)}},fingerprint:u}}let s=String(e);if(this.shouldIgnoreError(s))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),{type:"error",level:"error",message:s,timestamp:i,exception:{type:"Error",value:s,stacktrace:{frames:a}}}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`),r=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let i of n){let s=i.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(s){let[,a,c,u,d]=s,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,function:a,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}else{let a=i.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(a){let[,c,u,d]=a,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}}}return t.reverse()}destroy(){}},A=_;var B=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0}}setup(e){this.client=e,this.loadConfig(),this.shouldSample("default")&&(this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources())}loadConfig(){let e=this.client.config;e&&v(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"&&(t=(i=this.config.longTaskSampleRate)!=null?i:.05),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeLCP(){try{new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms")}}).observe({type:"largest-contentful-paint",buffered:!0})}catch(e){}}observeFID(){try{new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms")}}).observe({type:"first-input",buffered:!0})}catch(e){}}observeCLS(){try{let e=0,t=0,n=[];new PerformanceObserver(i=>{let s=i.getEntries();for(let a of s)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-u.endTime<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,""))}}).observe({type:"layout-shift",buffered:!0})}catch(e){}}observeFCP(){try{new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&this.reportMetric("FCP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeTTFB(){try{new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms")}}).observe({type:"navigation",buffered:!0})}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n)this.shouldSample("longtask")&&this.reportMetric("longtask",r.duration,"ms")}).observe({type:"longtask",buffered:!0})}catch(e){}}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&window.addEventListener("load",()=>{setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms")},0)})}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{new PerformanceObserver(t=>{if(!this.shouldSample("resource"))return;let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType})}).observe({type:"resource",buffered:!0})}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={}){let i=this.getRating(e,t),s={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:i,timestamp:p(),tags:{metric:e,rating:i,...r}};this.client.captureEvent(s)}destroy(){}},X=B;var D=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.patchFetch(),this.patchXHR()}loadConfig(){let e=this.client.config;e&&v(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return w(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,s,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((s=this.config.slowThreshold)!=null?s:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var y;let d=p()-r,f=u.ok,h=u.status;return(y=e.config.ignoreStatusCodes)!=null&&y.includes(h)||!e.shouldSample(!f,d)||(!f||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,status_code:h,duration:d,success:f,error:f?void 0:`HTTP ${h}`}),u},u=>{let d=p()-r;throw e.shouldSample(!0,d)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,duration:d,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=p(),r=this._lightMethod||"GET",i=this._lightUrl||"";if(e.shouldIgnore(i))return e.originalXHRSend.apply(this,arguments);let s=C(i),a=0;t&&typeof t=="string"&&(a=t.length);let c=()=>{var y;let u=p()-n,d=this.status,f=d>=200&&d<300;if((y=e.config.ignoreStatusCodes)!=null&&y.includes(d)){this.removeEventListener("loadend",c);return}if(!e.shouldSample(!f,u)){this.removeEventListener("loadend",c);return}if(f&&!e.config.captureSuccess){this.removeEventListener("loadend",c);return}let h=0;try{let P=this.getResponseHeader("content-length");P&&(h=parseInt(P,10)),!h&&this.responseText&&(h=this.responseText.length)}catch(P){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:s,status_code:d,duration:u,request_size:a||void 0,response_size:h||void 0,success:f,error:f?void 0:`HTTP ${d}`}),this.removeEventListener("loadend",c)};return this.addEventListener("loadend",c),e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:p(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},z=D;var O=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.1};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.shouldSample()&&(this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll())}loadConfig(){let e=this.client.config;e&&v(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.1;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=p(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let i=g();if(i!==e){let s=e,a=i;e=i,this.reportBehavior({sub_type:"route",page_url:a,referrer:s,properties:{from:s,to:a}}),this.pageEnterTime=p(),this.maxScrollDepth=0,this.scrollReported=!1}},n=history.pushState,r=history.replaceState;history.pushState=function(){let i=n.apply(this,arguments);return setTimeout(t,0),i},history.replaceState=function(){let i=r.apply(this,arguments);return setTimeout(t,0),i},window.addEventListener("popstate",()=>{setTimeout(t,0)}),window.addEventListener("hashchange",()=>{setTimeout(t,0)})}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=p();let e=()=>{let t=p()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:g(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};document.addEventListener("visibilitychange",()=>{document.hidden?e():this.pageEnterTime=p()}),window.addEventListener("beforeunload",e),window.addEventListener("pagehide",e)}trackScroll(){if(typeof window=="undefined")return;let e=()=>{var i;let t=p(),n=(i=this.config.scrollThrottle)!=null?i:1e3;if(t-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=r)};window.addEventListener("scroll",e,{passive:!0})}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:g(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){let t={type:"behavior",level:"info",timestamp:p(),tags:{sub_type:e.sub_type,page_url:g()},...e};this.client.captureEvent(t)}destroy(){}},V=O;var ne="light-sentry-offline",m="events",N=1e3,F=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:N};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(ne,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,window.addEventListener("online",()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()}),window.addEventListener("offline",()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1}))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(this.db)return new Promise((t,n)=>{let i=this.db.transaction([m],"readwrite").objectStore(m),s=i.count();s.onsuccess=async()=>{let a=s.result;a>=(this.config.maxEvents||N)&&await this.deleteOldestEvents(a-(this.config.maxEvents||N)+1);let c={id:this.generateId(),event:{...e,offline:!0},timestamp:p()},u=i.add(c);u.onsuccess=()=>t(),u.onerror=()=>n(u.error)}})}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let d=u.target.result;d&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let s=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();s.onsuccess=()=>{let a=s.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},s.onerror=()=>t(s.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=await this.getAllStoredEvents();if(e.length===0){console.info("[LightSDK] OfflinePlugin: No pending events to sync"),this.isSyncing=!1;return}let t=50;for(let n=0;ns.event);try{await(async()=>new Promise((a,c)=>{this.client.emit("sync:offline",i),a()}))(),console.info(`[LightSDK] OfflinePlugin: Synced ${r.length} events`)}catch(s){console.error("[LightSDK] OfflinePlugin: Failed to sync batch",s)}}await this.clearStoredEvents(),console.info("[LightSDK] OfflinePlugin: All pending events synced")}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline)return this.saveToIndexedDB(e).catch(n=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",n)}),null;let t={...e};return delete t.offline,t}destroy(){this.db&&(this.db.close(),this.db=null)}},Q=F;var l=null;function re(o){var r;let e=new K(o),t=[new A,new Q],n=((r=o.plugins)==null?void 0:r.filter(i=>typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new X),(n.includes("network")||n.includes("all"))&&t.push(new z),(n.includes("behavior")||n.includes("all"))&&t.push(new V);for(let i of t)e.use(i);if(o.plugins)for(let i of o.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function ie(){return l}function se(o){l==null||l.captureException(o)}function oe(o,e){l==null||l.captureMessage(o,e)}function ae(o){l==null||l.captureEvent(o)}function ce(o){l==null||l.setUser(o)}function ue(o,e){l==null||l.setTag(o,e)}function le(o){l==null||l.setTags(o)}function pe(o,e){l==null||l.setExtra(o,e)}function de(o){l==null||l.addBreadcrumb(o)}async function fe(){await(l==null?void 0:l.flush())}function he(){l==null||l.disable()}function ge(){l==null||l.enable()}var ct={init:re,getClient:ie,captureException:se,captureMessage:oe,captureEvent:ae,setUser:ce,setTag:ue,setTags:le,setExtra:pe,addBreadcrumb:de,flush:fe,disable:he,enable:ge};export{V as BehaviorPlugin,K as Client,A as ErrorPlugin,z as NetworkPlugin,Q as OfflinePlugin,X as PerformancePlugin,de as addBreadcrumb,ae as captureEvent,se as captureException,oe as captureMessage,ct as default,he as disable,ge as enable,fe as flush,ie as getClient,re as init,pe as setExtra,ue as setTag,le as setTags,ce as setUser}; +function K(o){try{let e=o.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function k(o){return`${o.protocol}://${o.host}/api/${o.projectId}/envelope/?sentry_key=${o.publicKey}`}function V(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}function d(){return Date.now()}function y(o){return o!==null&&typeof o=="object"&&!Array.isArray(o)}function T(o,e){return!e||e.length===0?!1:e.some(t=>typeof t=="string"?o.includes(t):t.test(o))}function oe(o){let e=5381,t=o.length;for(;t;)e=e*33^o.charCodeAt(--t);return e>>>0}function D(o){return oe(o).toString(16)}function L(o,e,t=[]){let n=t.slice(0,3).map(i=>`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return D(`${o}:${r}:${n}`)}function P(o,e={}){if(!o)return[];let{captureNodeModules:t=!1,captureColumn:n=!0,relativePathOnly:r=!1,maxFrames:i}=e,s=[],a=o.split(` +`),c=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let p of a){let w=p.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(w){let[,f,h,v,g]=w,E=h.includes("node_modules");if(!t&&E)continue;let S=h;r&&c&&h.startsWith(c)&&(S=h.substring(c.length)),s.push({filename:S,function:f,lineno:parseInt(v,10),colno:n?parseInt(g,10):void 0,in_app:!E})}else{let f=p.match(/^\s*at\s*(.+?)@(.+?):(\d+):(\d+)\s*$/);if(f){let[,v,g,E,S]=f,R=g.includes("node_modules");if(!t&&R)continue;let A=g;r&&c&&g.startsWith(c)&&(A=g.substring(c.length)),s.push({filename:A,function:v||"",lineno:parseInt(E,10),colno:n?parseInt(S,10):void 0,in_app:!R});continue}let h=p.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(h){let[,v,g,E]=h,S=v.includes("node_modules");if(!t&&S)continue;let R=v;r&&c&&v.startsWith(c)&&(R=v.substring(c.length)),s.push({filename:R,lineno:parseInt(g,10),colno:n?parseInt(E,10):void 0,in_app:!S})}}}let u=s.reverse();return i!==void 0&&i>0?u.slice(0,i):u}function ae(o){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function ce(o){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function ue(o){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(o),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(o),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",s=o.match(/iPhone/i),a=o.match(/iPad/i),c=o.match(/SM-([A-Z0-9]+)/i),u=o.match(/Pixel ([0-9A-Z]+)/i),p=o.match(/HUAWEI ([A-Z0-9]+)/i);return s?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):p&&(r="Huawei",i=p[1]),{type:n,vendor:r,model:i}}function z(){let o=typeof navigator!="undefined"?navigator.userAgent:"",e=ae(o),t=ce(o),n=ue(o);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:o,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function b(){return typeof location!="undefined"?location.href:""}function Q(){return typeof document!="undefined"?document.referrer:""}function x(o){if(!o)return o;try{let e=new URL(o),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let s=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>s.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}if(e.hash&&e.hash.length>1){let i=e.hash.substring(1);if(i.includes("=")){let s=new URLSearchParams(i),a=!1;for(let c of Array.from(s.keys())){let u=c.toLowerCase().replace(/[-_]/g,"_");t.some(p=>u.includes(p))&&(s.set(c,"[Filtered]"),a=!0)}a&&(e.hash="#"+s.toString(),r=!0)}}return r?e.toString():o}catch(e){return o}}function C(o,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(o)}return o.includes(t)}return t.test(o)})}var le={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},de={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function F(){let o=z(),e=le[o.browser.name]||o.browser.name.toLowerCase(),t=de[o.os.name]||o.os.name.toLowerCase();return{browser:{name:e,version:o.browser.major},os:{name:t,version:o.os.version.split(".")[0]},device:{type:o.device.type,brand:o.device.vendor||void 0,model:o.device.model||void 0}}}function G(){let o=z(),e=`${o.browser.name}|${o.browser.major}|${o.os.name}|${o.os.version.split(".")[0]}|${o.device.type}|${o.device.vendor}|${o.device.model}|${o.language}`;return D(e).substring(0,16)}var pe={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},M=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...pe,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}isIgnoredError(e){return T(e,this.config.ignoreErrors||[])}getEncodedContext(){return this.contexts||(this.contexts=F()),this.contexts}applyToEvent(e,t=!0){var i,s,a,c;let n={...e};if(e.tags&&(n.tags={...e.tags}),e.request&&(n.request={...e.request}),"exception"in e&&e.exception){let u=e.exception;n.exception={...u,stacktrace:u.stacktrace?{...u.stacktrace,frames:u.stacktrace.frames?u.stacktrace.frames.map(p=>({...p})):void 0}:void 0}}e.breadcrumbs&&(n.breadcrumbs=e.breadcrumbs.map(u=>({...u}))),e.user&&(n.user={...e.user}),e.contexts&&(n.contexts={...e.contexts}),this.config.release&&(n.release=this.config.release),this.config.environment&&(n.environment=this.config.environment),this.config.user&&(n.user=this.config.user),this.config.tags&&(n.tags={...this.config.tags,...e.tags||{}}),n.request?n.request.url||(n.request.url=b()):n.request={url:b(),referrer:Q()},this.contextId||(this.contextId=G()),n.context_id=this.contextId;let r=this.applyContextLevel(n);return t&&!this.contextReported&&(this.contexts||(this.contexts=F()),r.contexts=this.contexts,r.env={b:(i=this.contexts.browser)==null?void 0:i.name,bv:(s=this.contexts.browser)==null?void 0:s.version,os:(a=this.contexts.os)==null?void 0:a.name,osv:(c=this.contexts.os)==null?void 0:c.version}),r}applyContextLevel(e){let t=this.config.contextLevel;if(!t)return e;let n=e.level||"error",r=t[n]||t.error,i={...e};if("exception"in e&&e.exception&&typeof e.exception=="object"){let s=e.exception;if(s.stacktrace&&typeof s.stacktrace=="object"){let a=s.stacktrace;Array.isArray(a.frames)&&(r.maxStackFrames===0?delete i.exception:i.exception={...s,stacktrace:{...a,frames:a.frames.slice(0,r.maxStackFrames)}})}}return e.breadcrumbs&&Array.isArray(e.breadcrumbs)&&(r.maxBreadcrumbs===0?delete i.breadcrumbs:i.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs)),i}};var B=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var I=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.isFlushing=!1;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){this.onVisibilityChange=()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof document!="undefined"&&document.addEventListener("visibilitychange",this.onVisibilityChange),this.onBeforeUnload=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},this.onPagehide=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof window!="undefined"&&(window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide))}checkInfiniteLoop(e){let t=d(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;this.cleanupExpiredDedupeEntries();let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=d(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}cleanupExpiredDedupeEntries(){if(this.dedupeMap.size<100&&Math.random()>.1)return;let e=d(),t=6e4;for(let[n,r]of this.dedupeMap)e-r.lastTime>t&&this.dedupeMap.delete(n)}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:d(),level:"info"};e.push(r)}return e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0||this.isFlushing)return;this.isFlushing=!0;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=d(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.pendingCounts.clear(),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}finally{this.isFlushing=!1}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=new Map(this.pendingCounts),n=this.buildCountEvents(),r=[...e,...n];this.lastFlushTime=d(),this.eventBus.emit("report",r);try{this.syncFlushCallback(r),this.pendingCounts.clear(),this.eventBus.emit("reported",r)}catch(i){console.error("[LightSDK] Sync flush failed",i),e.forEach(s=>this.queue.unshift(s));for(let[s,a]of t)this.pendingCounts.has(s)||this.pendingCounts.set(s,a)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),typeof document!="undefined"&&this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange),typeof window!="undefined"&&(this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide)),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var J={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},qe=Object.fromEntries(Object.entries(J).map(([o,e])=>[e,o]));function Z(o){if(!o||typeof o!="object")return o;let e={};for(let[t,n]of Object.entries(o)){let r=J[t]||t;e[r]=n}return e}var _=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;let s=Math.min(this.retryDelay*Math.pow(2,n-1),5e3);await this.delay(s)}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:V(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),s=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=Z(c));let u=JSON.stringify(c),p=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});s.push(p,u)}return s.join(` +`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;is.release===t.release)&&(n.release=t.release),t.environment&&e.every(s=>s.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(s=>JSON.stringify(s.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}async send(e,t=!1){let n=k(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=new AbortController,i=setTimeout(()=>r.abort(),1e4),s=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0,signal:r.signal});if(clearTimeout(i),s.ok)return;let a=new Error(`HTTP ${s.status}`);throw a.status=s.status,a}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let s=new XMLHttpRequest;s.open("POST",n,!t),s.setRequestHeader("Content-Type","application/x-sentry-envelope"),s.timeout=1e4,s.onload=()=>{if(s.status>=200&&s.status<300)r();else{let a=new Error(`HTTP ${s.status}`);a.status=s.status,i(a)}},s.onerror=()=>i(new Error("Network error")),s.ontimeout=()=>i(new Error("Request timeout")),s.send(e)});throw new Error("No transport available")}sendSync(e){let t=k(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=k(this.dsn),n=new Image,r=encodeURIComponent(btoa(e)),i=t.includes("?")?"&":"?";return n.src=t+i+"sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var O=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var U=class{constructor(e){this.breadcrumbs=[];this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=K(e.dsn),this.configManager=new M(e),this.eventBus=new B,this.reporter=new _(this.dsn,(t=this.configManager.get("maxRetries"))!=null?t:3,(n=this.configManager.get("retryDelay"))!=null?n:1e3),this.pluginManager=new O(this),this.queue=new I((r=this.configManager.get("maxQueueSize"))!=null?r:100,(i=this.configManager.get("flushInterval"))!=null?i:5e3,this.eventBus,async s=>this.flushEvents(s),s=>this.syncFlushEvents(s)),this.enabled=this.configManager.get("enabled")!==!1}get maxBreadcrumbs(){var e;return(e=this.configManager.get("maxBreadcrumbs"))!=null?e:20}get config(){return this.configManager.getAll()}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled)return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled)return;let n={type:"error",level:t,message:e,timestamp:d(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled)return;let t={timestamp:d(),level:"info",breadcrumbs:[...this.breadcrumbs],...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=d();if(e instanceof Error){let r=P(e.stack,{maxFrames:5}),i=L(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:d()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},Y=U;var N=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1,resourceSampleRate:.1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&y(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls]),e.includePaths&&(this.config.includePaths=[...this.config.includePaths||[],...e.includePaths])}shouldIgnoreError(e){return e?T(e,this.config.ignoreErrors||[]):!1}shouldIgnoreScriptUrl(e){return C(e,this.config.ignoreUrls||[])}shouldIncludePath(e){let t=this.config.includePaths||[];return t.length===0?!0:e.some(n=>t.some(r=>{var i;return typeof r=="string"?(i=n.filename)==null?void 0:i.includes(r):r.test(n.filename||"")}))}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()=1?!0:e<=0?!1:Math.random(){try{if(this.originalOnError&&this.originalOnError.call(window,e,t,n,r,i),t&&this.shouldIgnoreScriptUrl(t))return!1;let s=this.buildErrorEvent(i||e,t,n,r);s&&this.client.captureEvent(s)}catch(s){console.error("[LightSDK] ErrorPlugin global error handler error:",s)}return!1})}setupUnhandledRejection(){typeof window!="undefined"&&(this.onUnhandledRejection=e=>{try{let t=e.reason,n=this.extractUrlFromReason(t);if(n&&this.shouldIgnoreScriptUrl(n))return;let r=this.buildErrorEvent(t);r&&(r.type==="error"&&(r.tags={...r.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(r))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}},window.addEventListener("unhandledrejection",this.onUnhandledRejection))}extractUrlFromReason(e){if(e instanceof Error&&e.stack){let t=e.stack.match(/at\s+(?:.+?\s+)?\((https?:\/\/[^:]+):\d+:\d+\)/);if(t)return t[1];let n=e.stack.match(/(https?:\/\/[^\s)]+):\d+:\d+/);if(n)return n[1]}return null}setupResourceError(){typeof window!="undefined"&&(this.onResourceError=e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{if(this.shouldIgnoreScriptUrl(r)||!this.shouldSampleResource())return;let s=`resource:${n}`,a=x(r);this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${a}`,timestamp:d(),fingerprint:s,tags:{resource_type:n,resource_url:a},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(s){console.error("[LightSDK] ErrorPlugin resource error handler error:",s)}},window.addEventListener("error",this.onResourceError,!0))}buildErrorEvent(e,t,n,r){let i=d();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=P(e.stack,{captureNodeModules:this.config.captureNodeModules,captureColumn:this.config.captureColumn,relativePathOnly:this.config.relativePathOnly,maxFrames:this.config.maxStackFrames});if(!this.shouldIncludePath(c)||!this.shouldSample())return null;let u=L(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c}},fingerprint:u}}let s=String(e);if(this.shouldIgnoreError(s))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),!this.shouldIncludePath(a)||!this.shouldSample()?null:{type:"error",level:"error",message:s,timestamp:i,exception:{type:"Error",value:s,stacktrace:{frames:a}}}}destroy(){typeof window!="undefined"&&(this.originalOnError!==void 0&&(window.onerror=this.originalOnError),this.onUnhandledRejection&&window.removeEventListener("unhandledrejection",this.onUnhandledRejection),this.onResourceError&&window.removeEventListener("error",this.onResourceError,!0))}},ee=N;var q=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0};this.observers=[];this.navLoadTimer=null;this.totalBlockingTime=0;this.fcpTime=null;this.lastLongTaskEndTime=0;this.ttiReported=!1;this.ttiTimer=null}setup(e){this.client=e,this.loadConfig(),this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources()}loadConfig(){let e=this.client.config;e&&y(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"?t=(i=this.config.longTaskSampleRate)!=null?i:.05:e==="vitals"&&(t=1),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms",{},"vitals")});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeLCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms",{},"vitals")}});e.observe({type:"largest-contentful-paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeFID(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms",{},"vitals")}});e.observe({type:"first-input",buffered:!0}),this.observers.push(e)}catch(e){}}observeCLS(){try{let e=0,t=0,n=[],r=new PerformanceObserver(i=>{let s=i.getEntries();for(let a of s)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-((u==null?void 0:u.duration)||0)<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,"",{},"vitals"))}});r.observe({type:"layout-shift",buffered:!0}),this.observers.push(r)}catch(e){}}observeFCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&(this.fcpTime=n[0].startTime,this.lastLongTaskEndTime=n[0].startTime,this.reportMetric("FCP",n[0].startTime,"ms",{},"vitals"),this.scheduleTTI())});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeTTFB(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms",{},"vitals")}});e.observe({type:"navigation",buffered:!0}),this.observers.push(e)}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{let e=new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n){let i=r.startTime+r.duration;if(i>this.lastLongTaskEndTime&&(this.lastLongTaskEndTime=i),this.fcpTime!==null&&r.startTime>=this.fcpTime){let s=r.duration-50;s>0&&(this.totalBlockingTime+=s)}this.reportMetric("longtask",r.duration,"ms",{},"longtask"),this.scheduleTTI()}});e.observe({type:"longtask",buffered:!0}),this.observers.push(e)}catch(e){}}scheduleTTI(){this.ttiReported||(this.ttiTimer&&clearTimeout(this.ttiTimer),this.ttiTimer=setTimeout(()=>{if(this.ttiReported)return;this.ttiReported=!0;let e=Math.max(this.lastLongTaskEndTime,this.fcpTime||0);this.reportMetric("TTI",e,"ms",{},"vitals"),this.reportMetric("TBT",this.totalBlockingTime,"ms",{},"vitals")},5e3))}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&(this.onLoad=()=>{this.navLoadTimer=setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms");this.navLoadTimer=null},0)},window.addEventListener("load",this.onLoad))}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType},"resource")});e.observe({type:"resource",buffered:!0}),this.observers.push(e)}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={},i="default"){if(!this.shouldSample(i))return;let s=this.getRating(e,t),a={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:s,timestamp:d(),tags:{metric:e,rating:s,...r}};this.client.captureEvent(a)}destroy(){for(let e of this.observers)try{e.disconnect()}catch(t){}this.observers=[],this.ttiTimer&&(clearTimeout(this.ttiTimer),this.ttiTimer=null),this.navLoadTimer&&(clearTimeout(this.navLoadTimer),this.navLoadTimer=null),typeof window!="undefined"&&this.onLoad&&window.removeEventListener("load",this.onLoad)}},te=q;var H=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureXHR:!0,captureFetch:!0,captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.config.captureFetch&&this.patchFetch(),this.config.captureXHR&&this.patchXHR()}loadConfig(){let e=this.client.config;e&&y(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return C(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,s,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((s=this.config.slowThreshold)!=null?s:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var h;let p=d()-r,w=u.ok,f=u.status;return(h=e.config.ignoreStatusCodes)!=null&&h.includes(f)||!e.shouldSample(!w,p)||(!w||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,status_code:f,duration:p,success:w,error:w?void 0:`HTTP ${f}`}),u},u=>{let p=d()-r;throw e.shouldSample(!0,p)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,duration:p,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),this._lightTracked=!1,e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=d(),r=this._lightMethod||"GET",i=this._lightUrl||"",s=this._lightTracked;if(e.shouldIgnore(i)||s)return e.originalXHRSend.apply(this,arguments);this._lightTracked=!0;let a=x(i),c=0;t&&typeof t=="string"&&(c=t.length);let u=this.onloadend;return this.onloadend=function(p){var v;let w=d()-n,f=this.status,h=f>=200&&f<300;if(!((v=e.config.ignoreStatusCodes)!=null&&v.includes(f))&&e.shouldSample(!h,w)&&(!h||e.config.captureSuccess)){let g=0;try{let E=this.getResponseHeader("content-length");E&&(g=parseInt(E,10)),!g&&this.responseText&&(g=this.responseText.length)}catch(E){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:a,status_code:f,duration:w,request_size:c||void 0,response_size:g||void 0,success:h,error:h?void 0:`HTTP ${f}`})}if(u)return u.call(this,p)},e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:d(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},ne=H;var j=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.01};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll()}loadConfig(){let e=this.client.config;e&&y(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.01;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=d(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let r=b();if(r!==e){let i=e,s=r;e=r,this.reportBehavior({sub_type:"route",page_url:s,referrer:i,properties:{from:i,to:s}}),this.pageEnterTime=d(),this.maxScrollDepth=0,this.scrollReported=!1}};this.originalPushState=history.pushState,this.originalReplaceState=history.replaceState;let n=this;history.pushState=function(){let r=n.originalPushState.apply(this,arguments);return setTimeout(t,0),r},history.replaceState=function(){let r=n.originalReplaceState.apply(this,arguments);return setTimeout(t,0),r},this.onPopState=()=>{setTimeout(t,0)},this.onHashchange=()=>{setTimeout(t,0)},window.addEventListener("popstate",this.onPopState),window.addEventListener("hashchange",this.onHashchange)}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=d();let e=()=>{let t=d()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:b(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};this.onVisibilityChange=()=>{document.hidden?e():this.pageEnterTime=d()},this.onBeforeUnload=e,this.onPagehide=e,document.addEventListener("visibilitychange",this.onVisibilityChange),window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide)}trackScroll(){typeof window!="undefined"&&(this.onScroll=()=>{var r;let e=d(),t=(r=this.config.scrollThrottle)!=null?r:1e3;if(e-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=n)},window.addEventListener("scroll",this.onScroll,{passive:!0}))}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:b(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){if(!this.shouldSample())return;let t={type:"behavior",level:"info",timestamp:d(),tags:{sub_type:e.sub_type,page_url:b()},...e};this.client.captureEvent(t)}destroy(){typeof window!="undefined"&&(this.originalPushState&&(history.pushState=this.originalPushState),this.originalReplaceState&&(history.replaceState=this.originalReplaceState),this.onPopState&&window.removeEventListener("popstate",this.onPopState),this.onHashchange&&window.removeEventListener("hashchange",this.onHashchange),this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide),this.onScroll&&window.removeEventListener("scroll",this.onScroll)),typeof document!="undefined"&&(this.onClick&&document.removeEventListener("click",this.onClick,!0),this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange))}},re=j;var he="light-sentry-offline",m="events",$=1e3,W=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:$};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(he,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result,s=r.oldVersion;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,this.onOnline=()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()},this.onOffline=()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1},window.addEventListener("online",this.onOnline),window.addEventListener("offline",this.onOffline))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(!this.db)return;let t=this.config.maxEvents||$,n=await this.getEventCount();return n>=t&&await this.deleteOldestEvents(n-t+1),new Promise((r,i)=>{let a=this.db.transaction([m],"readwrite").objectStore(m),c={id:this.generateId(),event:{...e,offline:!0},timestamp:d()},u=a.add(c);u.onsuccess=()=>r(),u.onerror=()=>i(u.error)})}getEventCount(){return this.db?new Promise((e,t)=>{let i=this.db.transaction([m],"readonly").objectStore(m).count();i.onsuccess=()=>e(i.result),i.onerror=()=>t(i.error)}):Promise.resolve(0)}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let p=u.target.result;p&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let s=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();s.onsuccess=()=>{let a=s.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},s.onerror=()=>t(s.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=0;for(;this.pendingEvents.length>0;){let n=this.pendingEvents.shift();try{this.client.captureEvent(n),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue memory event",r)}}let t=await this.getAllStoredEvents();if(t.length>0)for(let n of t)try{let r=n.event;this.client.captureEvent(r),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue event",r)}try{await this.client.flush()}catch(n){}await this.clearStoredEvents(),console.info(`[LightSDK] OfflinePlugin: Synced ${e} events`)}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline){let n=this.config.maxEvents||$;return this.pendingEvents.length>=n&&this.pendingEvents.shift(),this.pendingEvents.push(e),this.savePendingEventsToDB(),null}let t={...e};return delete t.offline,t}savePendingEventsToDB(){if(this.pendingEvents.length===0)return;let e=this.pendingEvents.splice(0,this.pendingEvents.length),t=0,n=()=>{if(t>=e.length)return;let r=e[t];this.saveToIndexedDB(r).then(()=>{t++,n()}).catch(i=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",i),this.pendingEvents.unshift(...e.slice(t))})};n()}destroy(){typeof window!="undefined"&&(this.onOnline&&window.removeEventListener("online",this.onOnline),this.onOffline&&window.removeEventListener("offline",this.onOffline)),this.db&&(this.db.close(),this.db=null)}},ie=W;var X=class{constructor(){this.name="sampling";this.version="1.0.0";this.config={rates:{error:1,performance:1,network:1,behavior:1}}}setup(e){this.client=e,this.loadConfig()}loadConfig(){let e=this.client.config;if(e&&y(e.sampling)){let t=e.sampling;"rates"in t&&y(t.rates)&&(this.config.rates={...this.config.rates,...t.rates})}}shouldSample(e){var r;let t=(r=this.client.config.sampleRate)!=null?r:1;if(t<=0)return!1;let n=this.getTypeRate(e);return t>=1?this.randomCheck(n):this.randomCheck(t*n)}getTypeRate(e){let t=this.config.rates||{},n=e.type;if(n==="count")return 1;let r=t[n];return r!==void 0?r:1}randomCheck(e){return e>=1?!0:e<=0?!1:Math.random()typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new te),(n.includes("network")||n.includes("all"))&&t.push(new ne),(n.includes("behavior")||n.includes("all"))&&t.push(new re);for(let i of t)e.use(i);if(o.plugins)for(let i of o.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function ge(){return l}function me(o){l==null||l.captureException(o)}function ve(o,e){l==null||l.captureMessage(o,e)}function ye(o){l==null||l.captureEvent(o)}function be(o){l==null||l.setUser(o)}function we(o,e){l==null||l.setTag(o,e)}function Ee(o){l==null||l.setTags(o)}function Se(o,e){l==null||l.setExtra(o,e)}function xe(o){l==null||l.addBreadcrumb(o)}async function Re(){await(l==null?void 0:l.flush())}function ke(){l==null||l.disable()}function Te(){l==null||l.enable()}var Pt={init:fe,getClient:ge,captureException:me,captureMessage:ve,captureEvent:ye,setUser:be,setTag:we,setTags:Ee,setExtra:Se,addBreadcrumb:xe,flush:Re,disable:ke,enable:Te};export{re as BehaviorPlugin,Y as Client,ee as ErrorPlugin,ne as NetworkPlugin,ie as OfflinePlugin,te as PerformancePlugin,se as SamplingPlugin,xe as addBreadcrumb,ye as captureEvent,me as captureException,ve as captureMessage,Pt as default,ke as disable,Te as enable,Re as flush,ge as getClient,fe as init,Se as setExtra,we as setTag,Ee as setTags,be as setUser}; diff --git a/dist/index.iife.js b/dist/index.iife.js index 3f67a65..4740687 100644 --- a/dist/index.iife.js +++ b/dist/index.iife.js @@ -1,4 +1,3 @@ -"use strict";var LightSDK=(()=>{var T=Object.defineProperty;var le=Object.getOwnPropertyDescriptor;var pe=Object.getOwnPropertyNames;var de=Object.prototype.hasOwnProperty;var fe=(s,e)=>{for(var t in e)T(s,t,{get:e[t],enumerable:!0})},he=(s,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of pe(e))!de.call(s,r)&&r!==t&&T(s,r,{get:()=>e[r],enumerable:!(n=le(e,r))||n.enumerable});return s};var ge=s=>he(T({},"__esModule",{value:!0}),s);var Re={};fe(Re,{BehaviorPlugin:()=>$,Client:()=>B,ErrorPlugin:()=>O,NetworkPlugin:()=>U,OfflinePlugin:()=>K,PerformancePlugin:()=>F,addBreadcrumb:()=>oe,captureEvent:()=>te,captureException:()=>Y,captureMessage:()=>ee,default:()=>xe,disable:()=>ce,enable:()=>ue,flush:()=>ae,getClient:()=>Z,init:()=>J,setExtra:()=>se,setTag:()=>re,setTags:()=>ie,setUser:()=>ne});function A(s){try{let e=s.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function b(s){return`${s.protocol}://${s.host}/${s.projectId}/envelope/`}function p(){return Date.now()}function v(s){return s!==null&&typeof s=="object"&&!Array.isArray(s)}function C(s){let e=0;for(let t=0;t`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return C(`${s}:${r}:${n}`)}function me(s){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=s.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function ve(s){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=s.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function ye(s){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(s),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(s),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",o=s.match(/iPhone/i),a=s.match(/iPad/i),c=s.match(/SM-([A-Z0-9]+)/i),u=s.match(/Pixel ([0-9A-Z]+)/i),d=s.match(/HUAWEI ([A-Z0-9]+)/i);return o?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):d&&(r="Huawei",i=d[1]),{type:n,vendor:r,model:i}}function X(){let s=typeof navigator!="undefined"?navigator.userAgent:"",e=me(s),t=ve(s),n=ye(s);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:s,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function g(){return typeof location!="undefined"?location.href:""}function z(){return typeof document!="undefined"?document.referrer:""}function M(s){if(!s)return s;try{let e=new URL(s),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let o=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>o.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}return r?e.toString():s}catch(e){return s}}function w(s,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(s)}return s.includes(t)}return t.test(s)})}var be={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},Ee={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function I(){let s=X(),e=be[s.browser.name]||s.browser.name.toLowerCase(),t=Ee[s.os.name]||s.os.name.toLowerCase();return{browser:{name:e,version:s.browser.major},os:{name:t,version:s.os.version.split(".")[0]},device:{type:s.device.type,brand:s.device.vendor||void 0,model:s.device.model||void 0}}}function V(){let s=X(),e=`${s.browser.name}|${s.browser.major}|${s.os.name}|${s.os.version.split(".")[0]}|${s.device.type}|${s.device.vendor}|${s.device.model}|${s.language}`;return C(e).substring(0,16)}var we={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},S=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...we,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()typeof n=="string"?e.includes(n):n.test(e))}getEncodedContext(){return this.contexts||(this.contexts=I()),this.contexts}applyToEvent(e,t=!0){var n,r,i,o;return this.config.release&&(e.release=this.config.release),this.config.environment&&(e.environment=this.config.environment),this.config.user&&(e.user=this.config.user),this.config.tags&&(e.tags={...this.config.tags,...e.tags||{}}),e.request?e.request.url||(e.request.url=g()):e.request={url:g(),referrer:z()},this.contextId||(this.contextId=V()),e.context_id=this.contextId,this.applyContextLevel(e),t&&!this.contextReported&&(this.contexts||(this.contexts=I()),e.contexts=this.contexts,e.env={b:(n=this.contexts.browser)==null?void 0:n.name,bv:(r=this.contexts.browser)==null?void 0:r.version,os:(i=this.contexts.os)==null?void 0:i.name,osv:(o=this.contexts.os)==null?void 0:o.version}),e}applyContextLevel(e){var i,o,a,c;let t=this.config.contextLevel;if(!t)return;let n=e.level||"error",r=t[n]||t.error;(o=(i=e.exception)==null?void 0:i.stacktrace)!=null&&o.frames&&r.maxStackFrames>0?e.exception.stacktrace.frames=e.exception.stacktrace.frames.slice(0,r.maxStackFrames):r.maxStackFrames===0&&((c=(a=e.exception)==null?void 0:a.stacktrace)!=null&&c.frames)&&delete e.exception.stacktrace,e.breadcrumbs&&r.maxBreadcrumbs>0?e.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs):r.maxBreadcrumbs===0&&delete e.breadcrumbs}};var x=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var R=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){typeof document!="undefined"&&document.addEventListener("visibilitychange",()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),typeof window!="undefined"&&(window.addEventListener("beforeunload",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}),window.addEventListener("pagehide",()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()}))}checkInfiniteLoop(e){let t=p(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=p(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.length>=this.maxSize&&this.flush(),this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:p(),level:"info"};e.push(r)}return this.pendingCounts.clear(),e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=p(),this.eventBus.emit("report",n);try{this.syncFlushCallback(n),this.eventBus.emit("reported",n)}catch(r){console.error("[LightSDK] Sync flush failed",r)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var Q={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},Ne=Object.fromEntries(Object.entries(Q).map(([s,e])=>[e,s]));function G(s){if(!s||typeof s!="object")return s;let e={};for(let[t,n]of Object.entries(s)){let r=Q[t]||t;e[r]=n}return e}var L=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;await this.delay(this.retryDelay*Math.pow(2,n-1))}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:this.generateEventId(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),o=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=G(c));let u=JSON.stringify(c),d=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});o.push(d,u)}return o.join(` -`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;io.release===t.release)&&(n.release=t.release),t.environment&&e.every(o=>o.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(o=>JSON.stringify(o.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}generateEventId(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}async send(e,t=!1){let n=b(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0});if(r.ok)return;let i=new Error(`HTTP ${r.status}`);throw i.status=r.status,i}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let o=new XMLHttpRequest;o.open("POST",n,!t),o.setRequestHeader("Content-Type","application/x-sentry-envelope"),o.onload=()=>{if(o.status>=200&&o.status<300)r();else{let a=new Error(`HTTP ${o.status}`);a.status=o.status,i(a)}},o.onerror=()=>i(new Error("Network error")),o.send(e)});throw new Error("No transport available")}sendSync(e){let t=b(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=b(this.dsn),n=new Image,r=encodeURIComponent(btoa(e));return n.src=t+"&sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var k=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var _=class{constructor(e){this.breadcrumbs=[];this.maxBreadcrumbs=20;this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=A(e.dsn),this.configManager=new S(e),this.config=this.configManager.getAll(),this.eventBus=new x,this.reporter=new L(this.dsn,(t=e.maxRetries)!=null?t:3,(n=e.retryDelay)!=null?n:1e3),this.pluginManager=new k(this),this.queue=new R((r=e.maxQueueSize)!=null?r:100,(i=e.flushInterval)!=null?i:5e3,this.eventBus,async o=>this.flushEvents(o),o=>this.syncFlushEvents(o)),this.enabled=e.enabled!==!1}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let o=this.pluginManager.applyBeforeReport(i);o&&t.push(o)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let o=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(o):o;a&&r.push(a)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let o=this.pluginManager.applyBeforeReport(i);o&&t.push(o)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let o=this.configManager.applyToEvent(i,n);n=!1;let a=this.config.get("beforeSend")?this.config.get("beforeSend")(o):o;a&&r.push(a)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled||!this.configManager.shouldSample())return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled||!this.configManager.shouldSample())return;let n={type:"error",level:t,message:e,timestamp:p(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled||!this.configManager.shouldSample())return;let t={timestamp:p(),level:"info",...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=p();if(e instanceof Error){let r=this.parseStackTrace(e.stack),i=E(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r.slice(0,5)}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`);for(let r of n){let i=r.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(i){let[,o,a,c,u]=i;t.push({filename:a,function:o,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}else{let o=r.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(o){let[,a,c,u]=o;t.push({filename:a,lineno:parseInt(c,10),colno:parseInt(u,10),in_app:!a.includes("node_modules")})}}}return t.reverse()}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:p()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){e&&typeof e=="object"&&"name"in e&&"setup"in e&&this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},B=_;var D=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&v(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnoreError(e){return!this.config.ignoreErrors||!e?!1:this.config.ignoreErrors.some(t=>typeof t=="string"?e.includes(t):t.test(e))}shouldIgnoreScriptUrl(e){return w(e,this.config.ignoreUrls)}setupGlobalError(){if(typeof window=="undefined")return;let e=window.onerror;window.onerror=(t,n,r,i,o)=>{try{e&&e.call(window,t,n,r,i,o);let a=this.buildErrorEvent(o||t,n,r,i);a&&this.client.captureEvent(a)}catch(a){console.error("[LightSDK] ErrorPlugin global error handler error:",a)}return!1}}setupUnhandledRejection(){typeof window!="undefined"&&window.addEventListener("unhandledrejection",e=>{try{let t=e.reason,n=this.buildErrorEvent(t);n&&(n.type==="error"&&(n.tags={...n.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(n))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}})}setupResourceError(){typeof window!="undefined"&&window.addEventListener("error",e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${r}`,timestamp:p(),tags:{resource_type:n,resource_url:r},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(o){console.error("[LightSDK] ErrorPlugin resource error handler error:",o)}},!0)}buildErrorEvent(e,t,n,r){let i=p();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=this.parseStackTrace(e.stack),u=E(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c.slice(0,this.config.maxStackFrames||5)}},fingerprint:u}}let o=String(e);if(this.shouldIgnoreError(o))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),{type:"error",level:"error",message:o,timestamp:i,exception:{type:"Error",value:o,stacktrace:{frames:a}}}}parseStackTrace(e){if(!e)return[];let t=[],n=e.split(` -`),r=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let i of n){let o=i.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(o){let[,a,c,u,d]=o,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,function:a,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}else{let a=i.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(a){let[,c,u,d]=a,f=c.includes("node_modules");if(!this.config.captureNodeModules&&f)continue;let h=c;this.config.relativePathOnly&&r&&c.startsWith(r)&&(h=c.substring(r.length)),t.push({filename:h,lineno:parseInt(u,10),colno:this.config.captureColumn?parseInt(d,10):void 0,in_app:!f})}}}return t.reverse()}destroy(){}},O=D;var N=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0}}setup(e){this.client=e,this.loadConfig(),this.shouldSample("default")&&(this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources())}loadConfig(){let e=this.client.config;e&&v(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"&&(t=(i=this.config.longTaskSampleRate)!=null?i:.05),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeLCP(){try{new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms")}}).observe({type:"largest-contentful-paint",buffered:!0})}catch(e){}}observeFID(){try{new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms")}}).observe({type:"first-input",buffered:!0})}catch(e){}}observeCLS(){try{let e=0,t=0,n=[];new PerformanceObserver(i=>{let o=i.getEntries();for(let a of o)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-u.endTime<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,""))}}).observe({type:"layout-shift",buffered:!0})}catch(e){}}observeFCP(){try{new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&this.reportMetric("FCP",n[0].startTime,"ms")}).observe({type:"paint",buffered:!0})}catch(e){}}observeTTFB(){try{new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms")}}).observe({type:"navigation",buffered:!0})}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n)this.shouldSample("longtask")&&this.reportMetric("longtask",r.duration,"ms")}).observe({type:"longtask",buffered:!0})}catch(e){}}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&window.addEventListener("load",()=>{setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms")},0)})}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{new PerformanceObserver(t=>{if(!this.shouldSample("resource"))return;let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType})}).observe({type:"resource",buffered:!0})}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={}){let i=this.getRating(e,t),o={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:i,timestamp:p(),tags:{metric:e,rating:i,...r}};this.client.captureEvent(o)}destroy(){}},F=N;var q=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.patchFetch(),this.patchXHR()}loadConfig(){let e=this.client.config;e&&v(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return w(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,o,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((o=this.config.slowThreshold)!=null?o:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var y;let d=p()-r,f=u.ok,h=u.status;return(y=e.config.ignoreStatusCodes)!=null&&y.includes(h)||!e.shouldSample(!f,d)||(!f||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:o.toUpperCase(),url:a,status_code:h,duration:d,success:f,error:f?void 0:`HTTP ${h}`}),u},u=>{let d=p()-r;throw e.shouldSample(!0,d)&&e.reportNetwork({sub_type:"fetch",method:o.toUpperCase(),url:a,duration:d,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=p(),r=this._lightMethod||"GET",i=this._lightUrl||"";if(e.shouldIgnore(i))return e.originalXHRSend.apply(this,arguments);let o=M(i),a=0;t&&typeof t=="string"&&(a=t.length);let c=()=>{var y;let u=p()-n,d=this.status,f=d>=200&&d<300;if((y=e.config.ignoreStatusCodes)!=null&&y.includes(d)){this.removeEventListener("loadend",c);return}if(!e.shouldSample(!f,u)){this.removeEventListener("loadend",c);return}if(f&&!e.config.captureSuccess){this.removeEventListener("loadend",c);return}let h=0;try{let P=this.getResponseHeader("content-length");P&&(h=parseInt(P,10)),!h&&this.responseText&&(h=this.responseText.length)}catch(P){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:o,status_code:d,duration:u,request_size:a||void 0,response_size:h||void 0,success:f,error:f?void 0:`HTTP ${d}`}),this.removeEventListener("loadend",c)};return this.addEventListener("loadend",c),e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:p(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},U=q;var H=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.1};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.shouldSample()&&(this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll())}loadConfig(){let e=this.client.config;e&&v(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.1;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=p(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let i=g();if(i!==e){let o=e,a=i;e=i,this.reportBehavior({sub_type:"route",page_url:a,referrer:o,properties:{from:o,to:a}}),this.pageEnterTime=p(),this.maxScrollDepth=0,this.scrollReported=!1}},n=history.pushState,r=history.replaceState;history.pushState=function(){let i=n.apply(this,arguments);return setTimeout(t,0),i},history.replaceState=function(){let i=r.apply(this,arguments);return setTimeout(t,0),i},window.addEventListener("popstate",()=>{setTimeout(t,0)}),window.addEventListener("hashchange",()=>{setTimeout(t,0)})}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=p();let e=()=>{let t=p()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:g(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};document.addEventListener("visibilitychange",()=>{document.hidden?e():this.pageEnterTime=p()}),window.addEventListener("beforeunload",e),window.addEventListener("pagehide",e)}trackScroll(){if(typeof window=="undefined")return;let e=()=>{var i;let t=p(),n=(i=this.config.scrollThrottle)!=null?i:1e3;if(t-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=r)};window.addEventListener("scroll",e,{passive:!0})}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:g(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){let t={type:"behavior",level:"info",timestamp:p(),tags:{sub_type:e.sub_type,page_url:g()},...e};this.client.captureEvent(t)}destroy(){}},$=H;var Se="light-sentry-offline",m="events",W=1e3,j=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:W};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(Se,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,window.addEventListener("online",()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()}),window.addEventListener("offline",()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1}))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(this.db)return new Promise((t,n)=>{let i=this.db.transaction([m],"readwrite").objectStore(m),o=i.count();o.onsuccess=async()=>{let a=o.result;a>=(this.config.maxEvents||W)&&await this.deleteOldestEvents(a-(this.config.maxEvents||W)+1);let c={id:this.generateId(),event:{...e,offline:!0},timestamp:p()},u=i.add(c);u.onsuccess=()=>t(),u.onerror=()=>n(u.error)}})}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let d=u.target.result;d&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let o=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();o.onsuccess=()=>{let a=o.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},o.onerror=()=>t(o.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=await this.getAllStoredEvents();if(e.length===0){console.info("[LightSDK] OfflinePlugin: No pending events to sync"),this.isSyncing=!1;return}let t=50;for(let n=0;no.event);try{await(async()=>new Promise((a,c)=>{this.client.emit("sync:offline",i),a()}))(),console.info(`[LightSDK] OfflinePlugin: Synced ${r.length} events`)}catch(o){console.error("[LightSDK] OfflinePlugin: Failed to sync batch",o)}}await this.clearStoredEvents(),console.info("[LightSDK] OfflinePlugin: All pending events synced")}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline)return this.saveToIndexedDB(e).catch(n=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",n)}),null;let t={...e};return delete t.offline,t}destroy(){this.db&&(this.db.close(),this.db=null)}},K=j;var l=null;function J(s){var r;let e=new B(s),t=[new O,new K],n=((r=s.plugins)==null?void 0:r.filter(i=>typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new F),(n.includes("network")||n.includes("all"))&&t.push(new U),(n.includes("behavior")||n.includes("all"))&&t.push(new $);for(let i of t)e.use(i);if(s.plugins)for(let i of s.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function Z(){return l}function Y(s){l==null||l.captureException(s)}function ee(s,e){l==null||l.captureMessage(s,e)}function te(s){l==null||l.captureEvent(s)}function ne(s){l==null||l.setUser(s)}function re(s,e){l==null||l.setTag(s,e)}function ie(s){l==null||l.setTags(s)}function se(s,e){l==null||l.setExtra(s,e)}function oe(s){l==null||l.addBreadcrumb(s)}async function ae(){await(l==null?void 0:l.flush())}function ce(){l==null||l.disable()}function ue(){l==null||l.enable()}var xe={init:J,getClient:Z,captureException:Y,captureMessage:ee,captureEvent:te,setUser:ne,setTag:re,setTags:ie,setExtra:se,addBreadcrumb:oe,flush:ae,disable:ce,enable:ue};return ge(Re);})(); +"use strict";var LightSDK=(()=>{var D=Object.defineProperty;var we=Object.getOwnPropertyDescriptor;var Ee=Object.getOwnPropertyNames;var Se=Object.prototype.hasOwnProperty;var xe=(o,e)=>{for(var t in e)D(o,t,{get:e[t],enumerable:!0})},Re=(o,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Ee(e))!Se.call(o,r)&&r!==t&&D(o,r,{get:()=>e[r],enumerable:!(n=we(e,r))||n.enumerable});return o};var ke=o=>Re(D({},"__esModule",{value:!0}),o);var De={};xe(De,{BehaviorPlugin:()=>V,Client:()=>q,ErrorPlugin:()=>j,NetworkPlugin:()=>A,OfflinePlugin:()=>G,PerformancePlugin:()=>W,SamplingPlugin:()=>Z,addBreadcrumb:()=>me,captureEvent:()=>de,captureException:()=>ue,captureMessage:()=>le,default:()=>Oe,disable:()=>ye,enable:()=>be,flush:()=>ve,getClient:()=>ce,init:()=>ae,setExtra:()=>ge,setTag:()=>he,setTags:()=>fe,setUser:()=>pe});function ee(o){try{let e=o.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);if(!e)throw new Error("Invalid DSN format");let[,t,n,r,i]=e;return{protocol:t,publicKey:n,host:r,projectId:i}}catch(e){throw new Error(`Failed to parse DSN: ${e.message}`)}}function k(o){return`${o.protocol}://${o.host}/api/${o.projectId}/envelope/?sentry_key=${o.publicKey}`}function te(){return"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g,()=>(Math.random()*16|0).toString(16))}function d(){return Date.now()}function y(o){return o!==null&&typeof o=="object"&&!Array.isArray(o)}function T(o,e){return!e||e.length===0?!1:e.some(t=>typeof t=="string"?o.includes(t):t.test(o))}function Te(o){let e=5381,t=o.length;for(;t;)e=e*33^o.charCodeAt(--t);return e>>>0}function F(o){return Te(o).toString(16)}function L(o,e,t=[]){let n=t.slice(0,3).map(i=>`${i.filename}:${i.lineno||0}`).join("|"),r=e.replace(/['"]?\d+['"]?/g,"").replace(/\s+/g," ").trim();return F(`${o}:${r}:${n}`)}function P(o,e={}){if(!o)return[];let{captureNodeModules:t=!1,captureColumn:n=!0,relativePathOnly:r=!1,maxFrames:i}=e,s=[],a=o.split(` +`),c=typeof location!="undefined"?`${location.protocol}//${location.host}`:"";for(let p of a){let w=p.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);if(w){let[,f,h,v,g]=w,E=h.includes("node_modules");if(!t&&E)continue;let S=h;r&&c&&h.startsWith(c)&&(S=h.substring(c.length)),s.push({filename:S,function:f,lineno:parseInt(v,10),colno:n?parseInt(g,10):void 0,in_app:!E})}else{let f=p.match(/^\s*at\s*(.+?)@(.+?):(\d+):(\d+)\s*$/);if(f){let[,v,g,E,S]=f,R=g.includes("node_modules");if(!t&&R)continue;let Y=g;r&&c&&g.startsWith(c)&&(Y=g.substring(c.length)),s.push({filename:Y,function:v||"",lineno:parseInt(E,10),colno:n?parseInt(S,10):void 0,in_app:!R});continue}let h=p.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);if(h){let[,v,g,E]=h,S=v.includes("node_modules");if(!t&&S)continue;let R=v;r&&c&&v.startsWith(c)&&(R=v.substring(c.length)),s.push({filename:R,lineno:parseInt(g,10),colno:n?parseInt(E,10):void 0,in_app:!S})}}}let u=s.reverse();return i!==void 0&&i>0?u.slice(0,i):u}function Le(o){let e=[{name:"WeChat",pattern:/MicroMessenger\/([\d.]+)/i},{name:"QQ Browser",pattern:/QIBrowser\/([\d.]+)/i},{name:"UC Browser",pattern:/UCBrowser\/([\d.]+)/i},{name:"Edge",pattern:/Edg\/([\d.]+)/i},{name:"Edge",pattern:/Edge\/([\d.]+)/i},{name:"Opera",pattern:/OPR\/([\d.]+)/i},{name:"Opera",pattern:/Opera\/([\d.]+)/i},{name:"Firefox",pattern:/Firefox\/([\d.]+)/i},{name:"Safari",pattern:/Version\/([\d.]+).*Safari/i},{name:"Chrome",pattern:/Chrome\/([\d.]+)/i},{name:"IE",pattern:/MSIE ([\d.]+)/i},{name:"IE",pattern:/Trident.*rv:([\d.]+)/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"",i=r.split(".")[0]||"";return{name:t.name,version:r,major:i}}}return{name:"Unknown",version:"",major:""}}function Pe(o){let e=[{name:"Windows 11",pattern:/Windows NT 10\.0.*Win64.*x64/i},{name:"Windows 10",pattern:/Windows NT 10\.0/i},{name:"Windows 8.1",pattern:/Windows NT 6\.3/i},{name:"Windows 8",pattern:/Windows NT 6\.2/i},{name:"Windows 7",pattern:/Windows NT 6\.1/i},{name:"Windows Vista",pattern:/Windows NT 6\.0/i},{name:"Windows XP",pattern:/Windows NT 5\.1/i},{name:"macOS",pattern:/Mac OS X ([\d_.]+)/i},{name:"iOS",pattern:/OS ([\d_.]+) like Mac OS X/i},{name:"Android",pattern:/Android ([\d.]+)/i},{name:"Android",pattern:/Android/i},{name:"Linux",pattern:/Linux/i}];for(let t of e){let n=o.match(t.pattern);if(n){let r=n[1]||"";return r&&(r=r.replace(/_/g,".")),{name:t.name,version:r}}}return{name:"Unknown",version:""}}function Ce(o){let e=/Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(o),t=/Tablet|iPad|PlayBook|Kindle|Silk/i.test(o),n="desktop";t?n="tablet":e&&(n="mobile");let r="",i="",s=o.match(/iPhone/i),a=o.match(/iPad/i),c=o.match(/SM-([A-Z0-9]+)/i),u=o.match(/Pixel ([0-9A-Z]+)/i),p=o.match(/HUAWEI ([A-Z0-9]+)/i);return s?(r="Apple",i="iPhone"):a?(r="Apple",i="iPad"):c?(r="Samsung",i=c[1]):u?(r="Google",i=`Pixel ${u[1]}`):p&&(r="Huawei",i=p[1]),{type:n,vendor:r,model:i}}function ne(){let o=typeof navigator!="undefined"?navigator.userAgent:"",e=Le(o),t=Pe(o),n=Ce(o);return{browser:e,os:t,device:n,url:typeof location!="undefined"?location.href:"",referrer:typeof document!="undefined"?document.referrer:"",title:typeof document!="undefined"?document.title:"",language:typeof navigator!="undefined"?navigator.language:"",user_agent:o,screen_width:typeof screen!="undefined"?screen.width:0,screen_height:typeof screen!="undefined"?screen.height:0,viewport_width:typeof window!="undefined"?window.innerWidth:0,viewport_height:typeof window!="undefined"?window.innerHeight:0}}function b(){return typeof location!="undefined"?location.href:""}function re(){return typeof document!="undefined"?document.referrer:""}function x(o){if(!o)return o;try{let e=new URL(o),t=["password","passwd","pwd","token","access_token","refresh_token","api_key","apikey","key","secret","private_key","code","auth","id_token","idToken","session","session_id"],n=e.searchParams,r=!1;for(let i of Array.from(n.keys())){let s=i.toLowerCase().replace(/[-_]/g,"_");t.some(a=>s.includes(a))&&(n.set(i,"[Filtered]"),r=!0)}if(e.hash&&e.hash.length>1){let i=e.hash.substring(1);if(i.includes("=")){let s=new URLSearchParams(i),a=!1;for(let c of Array.from(s.keys())){let u=c.toLowerCase().replace(/[-_]/g,"_");t.some(p=>u.includes(p))&&(s.set(c,"[Filtered]"),a=!0)}a&&(e.hash="#"+s.toString(),r=!0)}}return r?e.toString():o}catch(e){return o}}function C(o,e=[]){return!e||e.length===0?!1:e.some(t=>{if(typeof t=="string"){if(t.includes("*")){let n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(n).test(o)}return o.includes(t)}return t.test(o)})}var Me={Chrome:"c",Safari:"s",Firefox:"f",Edge:"e",Opera:"o",IE:"i",WeChat:"w","QQ Browser":"q","UC Browser":"u","Mobile Chrome":"cm","Mobile Safari":"sm"},Be={"Windows 11":"w11","Windows 10":"w10","Windows 8.1":"w81","Windows 8":"w8","Windows 7":"w7","Windows Vista":"wv","Windows XP":"wxp",macOS:"m",iOS:"i",Android:"a",Linux:"l"};function U(){let o=ne(),e=Me[o.browser.name]||o.browser.name.toLowerCase(),t=Be[o.os.name]||o.os.name.toLowerCase();return{browser:{name:e,version:o.browser.major},os:{name:t,version:o.os.version.split(".")[0]},device:{type:o.device.type,brand:o.device.vendor||void 0,model:o.device.model||void 0}}}function ie(){let o=ne(),e=`${o.browser.name}|${o.browser.major}|${o.os.name}|${o.os.version.split(".")[0]}|${o.device.type}|${o.device.vendor}|${o.device.model}|${o.language}`;return F(e).substring(0,16)}var Ie={enabled:!0,sampleRate:1,maxQueueSize:100,flushInterval:5e3,maxRetries:3,retryDelay:1e3,environment:"production",contextLevel:{fatal:{maxStackFrames:5,maxBreadcrumbs:20},error:{maxStackFrames:5,maxBreadcrumbs:10},warning:{maxStackFrames:3,maxBreadcrumbs:5},info:{maxStackFrames:0,maxBreadcrumbs:0},debug:{maxStackFrames:0,maxBreadcrumbs:0}}},M=class{constructor(e){this.contexts=null;this.contextId=null;this.contextReported=!1;this.config={...Ie,...e}}markContextReported(){this.contextReported=!0}get(e){return this.config[e]}getAll(){return{...this.config}}set(e,t){this.config[e]=t}setUser(e){this.config.user=e||void 0}setTags(e){this.config.tags={...this.config.tags||{},...e}}setTag(e,t){this.config.tags||(this.config.tags={}),this.config.tags[e]=t}setExtra(e,t){this.config.extra||(this.config.extra={}),this.config.extra[e]=t}isIgnoredError(e){return T(e,this.config.ignoreErrors||[])}getEncodedContext(){return this.contexts||(this.contexts=U()),this.contexts}applyToEvent(e,t=!0){var i,s,a,c;let n={...e};if(e.tags&&(n.tags={...e.tags}),e.request&&(n.request={...e.request}),"exception"in e&&e.exception){let u=e.exception;n.exception={...u,stacktrace:u.stacktrace?{...u.stacktrace,frames:u.stacktrace.frames?u.stacktrace.frames.map(p=>({...p})):void 0}:void 0}}e.breadcrumbs&&(n.breadcrumbs=e.breadcrumbs.map(u=>({...u}))),e.user&&(n.user={...e.user}),e.contexts&&(n.contexts={...e.contexts}),this.config.release&&(n.release=this.config.release),this.config.environment&&(n.environment=this.config.environment),this.config.user&&(n.user=this.config.user),this.config.tags&&(n.tags={...this.config.tags,...e.tags||{}}),n.request?n.request.url||(n.request.url=b()):n.request={url:b(),referrer:re()},this.contextId||(this.contextId=ie()),n.context_id=this.contextId;let r=this.applyContextLevel(n);return t&&!this.contextReported&&(this.contexts||(this.contexts=U()),r.contexts=this.contexts,r.env={b:(i=this.contexts.browser)==null?void 0:i.name,bv:(s=this.contexts.browser)==null?void 0:s.version,os:(a=this.contexts.os)==null?void 0:a.name,osv:(c=this.contexts.os)==null?void 0:c.version}),r}applyContextLevel(e){let t=this.config.contextLevel;if(!t)return e;let n=e.level||"error",r=t[n]||t.error,i={...e};if("exception"in e&&e.exception&&typeof e.exception=="object"){let s=e.exception;if(s.stacktrace&&typeof s.stacktrace=="object"){let a=s.stacktrace;Array.isArray(a.frames)&&(r.maxStackFrames===0?delete i.exception:i.exception={...s,stacktrace:{...a,frames:a.frames.slice(0,r.maxStackFrames)}})}}return e.breadcrumbs&&Array.isArray(e.breadcrumbs)&&(r.maxBreadcrumbs===0?delete i.breadcrumbs:i.breadcrumbs=e.breadcrumbs.slice(-r.maxBreadcrumbs)),i}};var B=class{constructor(){this.handlers=new Map}on(e,t){this.handlers.has(e)||this.handlers.set(e,[]),this.handlers.get(e).push(t)}off(e,t){let n=this.handlers.get(e);if(n){let r=n.indexOf(t);r>-1&&n.splice(r,1)}}emit(e,...t){let n=this.handlers.get(e);if(n)for(let r of n)try{r(...t)}catch(i){console.error("[LightSDK] EventBus handler error:",i)}}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}destroy(){this.handlers.clear()}};var I=class{constructor(e,t,n,r,i){this.queue=[];this.timer=null;this.lastFlushTime=0;this.isFlushing=!1;this.dedupeMap=new Map;this.pendingCounts=new Map;this.errorRateWindow={};this.paused=!1;this.pauseTimer=null;this.maxSize=e,this.flushInterval=t,this.eventBus=n,this.flushCallback=r,this.syncFlushCallback=i}start(){this.startTimer(),this.setupVisibilityListener()}startTimer(){this.timer||(this.timer=setInterval(()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flush()},this.flushInterval))}setupVisibilityListener(){this.onVisibilityChange=()=>{document.hidden&&(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof document!="undefined"&&document.addEventListener("visibilitychange",this.onVisibilityChange),this.onBeforeUnload=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},this.onPagehide=()=>{(this.queue.length>0||this.pendingCounts.size>0)&&this.flushSync()},typeof window!="undefined"&&(window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide))}checkInfiniteLoop(e){let t=d(),n=t-1e3;this.errorRateWindow[e]||(this.errorRateWindow[e]=[]);let r=this.errorRateWindow[e];for(r.push(t);r.length>0&&r[0]10?(this.paused||(console.warn("[LightSDK] Infinite loop detected, pausing SDK for 60s",{fingerprint:e,count:r.length}),this.eventBus.emit("error",new Error("Infinite loop detected")),this.paused=!0,this.pauseTimer&&clearTimeout(this.pauseTimer),this.pauseTimer=setTimeout(()=>{this.paused=!1,this.errorRateWindow={},console.info("[LightSDK] Resumed after infinite loop detection")},6e4)),!0):!1}enqueue(e){if(this.paused)return;this.cleanupExpiredDedupeEntries();let t=this.getDedupeKey(e);if(t){if(this.checkInfiniteLoop(t))return;let n=d(),r=this.dedupeMap.get(t);if(r)if(n-r.lastTime<6e4){if(r.count>=3){if(!this.pendingCounts.has(t))this.pendingCounts.set(t,{count:1,ts_start:n,ts_end:n});else{let i=this.pendingCounts.get(t);i.count++,i.ts_end=n}return}r.count++,r.lastTime=n}else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!0}),this.pendingCounts.delete(t);else this.dedupeMap.set(t,{count:1,lastTime:n,firstReported:!1})}this.queue.push(e),this.eventBus.emit("event",e),this.queue.length>=this.maxSize&&this.flush()}cleanupExpiredDedupeEntries(){if(this.dedupeMap.size<100&&Math.random()>.1)return;let e=d(),t=6e4;for(let[n,r]of this.dedupeMap)e-r.lastTime>t&&this.dedupeMap.delete(n)}buildCountEvents(){let e=[];for(let[t,n]of this.pendingCounts.entries()){let r={type:"count",fingerprint:t,count:n.count,ts_start:n.ts_start,ts_end:n.ts_end,timestamp:d(),level:"info"};e.push(r)}return e}getDedupeKey(e){if(e.type==="error"){let t=e;return t.fingerprint||t.message}return null}async flush(){if(this.queue.length===0&&this.pendingCounts.size===0||this.isFlushing)return;this.isFlushing=!0;let e=this.queue.splice(0,this.queue.length),t=this.buildCountEvents(),n=[...e,...t];this.lastFlushTime=d(),this.eventBus.emit("report",n);try{await this.flushCallback(n),this.pendingCounts.clear(),this.eventBus.emit("reported",n)}catch(r){throw e.forEach(i=>this.queue.unshift(i)),r}finally{this.isFlushing=!1}}flushSync(){if(this.queue.length===0&&this.pendingCounts.size===0)return;let e=this.queue.splice(0,this.queue.length),t=new Map(this.pendingCounts),n=this.buildCountEvents(),r=[...e,...n];this.lastFlushTime=d(),this.eventBus.emit("report",r);try{this.syncFlushCallback(r),this.pendingCounts.clear(),this.eventBus.emit("reported",r)}catch(i){console.error("[LightSDK] Sync flush failed",i),e.forEach(s=>this.queue.unshift(s));for(let[s,a]of t)this.pendingCounts.has(s)||this.pendingCounts.set(s,a)}}size(){return this.queue.length}destroy(){this.timer&&(clearInterval(this.timer),this.timer=null),this.pauseTimer&&(clearTimeout(this.pauseTimer),this.pauseTimer=null),typeof document!="undefined"&&this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange),typeof window!="undefined"&&(this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide)),this.dedupeMap.clear(),this.pendingCounts.clear(),this.errorRateWindow={}}};var se={event_id:"eid",timestamp:"ts",start_timestamp:"sts",type:"t",level:"l",message:"m",platform:"p",release:"r",environment:"e",fingerprint:"fp",context_id:"cid",user:"u",tags:"tg",exception:"ex",request:"req",contexts:"ctx",breadcrumbs:"bc",transaction:"tx",duration:"d",spans:"sp",extra:"xt"},Qe=Object.fromEntries(Object.entries(se).map(([o,e])=>[e,o]));function oe(o){if(!o||typeof o!="object")return o;let e={};for(let[t,n]of Object.entries(o)){let r=se[t]||t;e[r]=n}return e}var _=class{constructor(e,t,n){this.useShortFields=!0;this.dsn=e,this.maxRetries=t,this.retryDelay=n}async report(e){if(e.length===0)return;let t=this.buildEnvelope(e),n=0;for(;n<=this.maxRetries;)try{await this.send(t,!1);return}catch(r){let i=r.status;if(i&&i>=400&&i<500||(n++,n>this.maxRetries))throw r;let s=Math.min(this.retryDelay*Math.pow(2,n-1),5e3);await this.delay(s)}}reportSync(e){if(e.length===0)return;let t=this.buildEnvelope(e);try{if(this.sendSync(t))return}catch(n){}this.sendViaImage(t)}buildEnvelope(e){let t=this.extractSharedMeta(e),n={event_id:te(),sent_at:new Date().toISOString(),meta:t};this.useShortFields&&(n._sf=1);let r=this.applyRelativeTimestamps(e,n),s=[JSON.stringify(n)];for(let a of r){let c=this.stripSharedFields(a,t);this.useShortFields&&(c=oe(c));let u=JSON.stringify(c),p=JSON.stringify({type:this.getEnvelopeType(a),length:u.length});s.push(p,u)}return s.join(` +`)}applyRelativeTimestamps(e,t){if(e.length<=1)return e;let n=[],r=e[0].timestamp?new Date(e[0].timestamp).getTime():Date.now();t._rt=1,t._bt=r;for(let i=0;is.release===t.release)&&(n.release=t.release),t.environment&&e.every(s=>s.environment===t.environment)&&(n.environment=t.environment),t.user&&e.every(s=>JSON.stringify(s.user)===JSON.stringify(t.user))&&(n.user=t.user);let r=t.env;return r&&(n.env=r),n}stripSharedFields(e,t){let n={...e};return t.release&&n.release===t.release&&delete n.release,t.environment&&n.environment===t.environment&&delete n.environment,t.user&&JSON.stringify(n.user)===JSON.stringify(t.user)&&delete n.user,t.env&&delete n.env,n}getEnvelopeType(e){switch(e.type){case"error":return"event";case"performance":return"transaction";default:return e.type}}async send(e,t=!1){let n=k(this.dsn);if(navigator.sendBeacon)try{let r=new Blob([e],{type:"application/x-sentry-envelope"});if(navigator.sendBeacon(n,r))return}catch(r){}if(typeof fetch=="function")try{let r=new AbortController,i=setTimeout(()=>r.abort(),1e4),s=await fetch(n,{method:"POST",body:e,headers:{"Content-Type":"application/x-sentry-envelope"},keepalive:!0,signal:r.signal});if(clearTimeout(i),s.ok)return;let a=new Error(`HTTP ${s.status}`);throw a.status=s.status,a}catch(r){throw r}if(typeof XMLHttpRequest!="undefined")return new Promise((r,i)=>{let s=new XMLHttpRequest;s.open("POST",n,!t),s.setRequestHeader("Content-Type","application/x-sentry-envelope"),s.timeout=1e4,s.onload=()=>{if(s.status>=200&&s.status<300)r();else{let a=new Error(`HTTP ${s.status}`);a.status=s.status,i(a)}},s.onerror=()=>i(new Error("Network error")),s.ontimeout=()=>i(new Error("Request timeout")),s.send(e)});throw new Error("No transport available")}sendSync(e){let t=k(this.dsn);if(navigator.sendBeacon)try{let n=new Blob([e],{type:"application/x-sentry-envelope"});return navigator.sendBeacon(t,n)}catch(n){return!1}if(typeof XMLHttpRequest!="undefined")try{let n=new XMLHttpRequest;return n.open("POST",t,!1),n.setRequestHeader("Content-Type","application/x-sentry-envelope"),n.send(e),n.status>=200&&n.status<300}catch(n){return!1}return!1}sendViaImage(e){try{let t=k(this.dsn),n=new Image,r=encodeURIComponent(btoa(e)),i=t.includes("?")?"&":"?";return n.src=t+i+"sentry_data="+r.substring(0,2e3),!0}catch(t){return!1}}delay(e){return new Promise(t=>setTimeout(t,e))}};var O=class{constructor(e){this.plugins=new Map;this.client=e}add(e){if(!this.plugins.has(e.name)){this.plugins.set(e.name,e);try{e.setup(this.client)}catch(t){console.error(`[LightSDK] Plugin "${e.name}" setup error:`,t)}}}remove(e){let t=this.plugins.get(e);if(t){if(t.destroy)try{t.destroy()}catch(n){console.error(`[LightSDK] Plugin "${e}" destroy error:`,n)}this.plugins.delete(e)}}get(e){return this.plugins.get(e)}has(e){return this.plugins.has(e)}applyBeforeReport(e){let t=e;for(let n of this.plugins.values())if(n.beforeReport)try{let r=n.beforeReport(t);if(r===null)return null;t=r}catch(r){console.error(`[LightSDK] Plugin "${n.name}" beforeReport error:`,r)}return t}applyAfterReport(e){for(let t of this.plugins.values())if(t.afterReport)try{t.afterReport(e)}catch(n){console.error(`[LightSDK] Plugin "${t.name}" afterReport error:`,n)}}destroy(){for(let e of this.plugins.values())if(e.destroy)try{e.destroy()}catch(t){console.error(`[LightSDK] Plugin "${e.name}" destroy error:`,t)}this.plugins.clear()}};var N=class{constructor(e){this.breadcrumbs=[];this.enabled=!0;var t,n,r,i;if(!e.dsn)throw new Error("DSN is required");this.dsn=ee(e.dsn),this.configManager=new M(e),this.eventBus=new B,this.reporter=new _(this.dsn,(t=this.configManager.get("maxRetries"))!=null?t:3,(n=this.configManager.get("retryDelay"))!=null?n:1e3),this.pluginManager=new O(this),this.queue=new I((r=this.configManager.get("maxQueueSize"))!=null?r:100,(i=this.configManager.get("flushInterval"))!=null?i:5e3,this.eventBus,async s=>this.flushEvents(s),s=>this.syncFlushEvents(s)),this.enabled=this.configManager.get("enabled")!==!1}get maxBreadcrumbs(){var e;return(e=this.configManager.get("maxBreadcrumbs"))!=null?e:20}get config(){return this.configManager.getAll()}init(){this.enabled&&(this.queue.start(),this.emit("ready"))}async flushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}if(r.length!==0){await this.reporter.report(r),this.configManager.markContextReported();for(let i of r)this.pluginManager.applyAfterReport(i)}}syncFlushEvents(e){let t=[];for(let i of e){let s=this.pluginManager.applyBeforeReport(i);s&&t.push(s)}if(t.length===0)return;let n=!0,r=[];for(let i of t){let s=this.configManager.applyToEvent(i,n);n=!1;let a=this.configManager.get("beforeSend"),c=a?a(s):s;c&&r.push(c)}r.length!==0&&(this.reporter.reportSync(r),this.configManager.markContextReported())}on(e,t){this.eventBus.on(e,t)}off(e,t){this.eventBus.off(e,t)}emit(e,...t){this.eventBus.emit(e,...t)}captureException(e){if(!this.enabled)return;let t=this.buildErrorEvent(e);this.configManager.isIgnoredError(t.message)||(t.breadcrumbs=[...this.breadcrumbs],this.queue.enqueue(t))}captureMessage(e,t="info"){if(!this.enabled)return;let n={type:"error",level:t,message:e,timestamp:d(),breadcrumbs:[...this.breadcrumbs]};this.configManager.isIgnoredError(e)||this.queue.enqueue(n)}captureEvent(e){if(!this.enabled)return;let t={timestamp:d(),level:"info",breadcrumbs:[...this.breadcrumbs],...e};this.queue.enqueue(t)}buildErrorEvent(e){let t=d();if(e instanceof Error){let r=P(e.stack,{maxFrames:5}),i=L(e.name,e.message,r);return{type:"error",level:"error",message:e.message,timestamp:t,exception:{type:e.name,value:e.message,stacktrace:{frames:r}},fingerprint:i}}return{type:"error",level:"error",message:String(e),timestamp:t}}setUser(e){this.configManager.setUser(e)}setTag(e,t){this.configManager.setTag(e,t)}setTags(e){this.configManager.setTags(e)}setExtra(e,t){this.configManager.setExtra(e,t)}addBreadcrumb(e){let t={...e,timestamp:d()};this.breadcrumbs.push(t),this.breadcrumbs.length>this.maxBreadcrumbs&&this.breadcrumbs.shift()}async flush(){await this.queue.flush()}disable(){this.enabled=!1}enable(){this.enabled=!0}use(e){this.pluginManager.add(e)}destroy(){this.queue.destroy(),this.pluginManager.destroy(),this.eventBus.destroy(),this.breadcrumbs=[]}},q=N;var H=class{constructor(){this.name="error";this.version="1.0.0";this.config={maxStackFrames:5,captureNodeModules:!1,captureColumn:!0,relativePathOnly:!1,resourceSampleRate:.1}}setup(e){this.client=e,this.loadConfig(),this.setupGlobalError(),this.setupUnhandledRejection(),this.setupResourceError()}loadConfig(){let e=this.client.config;e&&y(e.error)&&(this.config={...this.config,...e.error}),e.ignoreErrors&&(this.config.ignoreErrors=[...this.config.ignoreErrors||[],...e.ignoreErrors]),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls]),e.includePaths&&(this.config.includePaths=[...this.config.includePaths||[],...e.includePaths])}shouldIgnoreError(e){return e?T(e,this.config.ignoreErrors||[]):!1}shouldIgnoreScriptUrl(e){return C(e,this.config.ignoreUrls||[])}shouldIncludePath(e){let t=this.config.includePaths||[];return t.length===0?!0:e.some(n=>t.some(r=>{var i;return typeof r=="string"?(i=n.filename)==null?void 0:i.includes(r):r.test(n.filename||"")}))}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:1;return e>=1?!0:e<=0?!1:Math.random()=1?!0:e<=0?!1:Math.random(){try{if(this.originalOnError&&this.originalOnError.call(window,e,t,n,r,i),t&&this.shouldIgnoreScriptUrl(t))return!1;let s=this.buildErrorEvent(i||e,t,n,r);s&&this.client.captureEvent(s)}catch(s){console.error("[LightSDK] ErrorPlugin global error handler error:",s)}return!1})}setupUnhandledRejection(){typeof window!="undefined"&&(this.onUnhandledRejection=e=>{try{let t=e.reason,n=this.extractUrlFromReason(t);if(n&&this.shouldIgnoreScriptUrl(n))return;let r=this.buildErrorEvent(t);r&&(r.type==="error"&&(r.tags={...r.tags||{},unhandled:"true",promise:"true"}),this.client.captureEvent(r))}catch(t){console.error("[LightSDK] ErrorPlugin unhandledrejection handler error:",t)}},window.addEventListener("unhandledrejection",this.onUnhandledRejection))}extractUrlFromReason(e){if(e instanceof Error&&e.stack){let t=e.stack.match(/at\s+(?:.+?\s+)?\((https?:\/\/[^:]+):\d+:\d+\)/);if(t)return t[1];let n=e.stack.match(/(https?:\/\/[^\s)]+):\d+:\d+/);if(n)return n[1]}return null}setupResourceError(){typeof window!="undefined"&&(this.onResourceError=e=>{var i;let t=e.target;if(!t)return;let n=(i=t.tagName)==null?void 0:i.toLowerCase(),r=t.src||t.href;if(n&&r&&["img","script","link","audio","video"].includes(n))try{if(this.shouldIgnoreScriptUrl(r)||!this.shouldSampleResource())return;let s=`resource:${n}`,a=x(r);this.client.captureEvent({type:"error",level:"warning",message:`Resource load failed: ${n} ${a}`,timestamp:d(),fingerprint:s,tags:{resource_type:n,resource_url:a},exception:{type:"ResourceError",value:`Failed to load ${n} resource`}})}catch(s){console.error("[LightSDK] ErrorPlugin resource error handler error:",s)}},window.addEventListener("error",this.onResourceError,!0))}buildErrorEvent(e,t,n,r){let i=d();if(e instanceof Error){if(this.shouldIgnoreError(e.message))return null;let c=P(e.stack,{captureNodeModules:this.config.captureNodeModules,captureColumn:this.config.captureColumn,relativePathOnly:this.config.relativePathOnly,maxFrames:this.config.maxStackFrames});if(!this.shouldIncludePath(c)||!this.shouldSample())return null;let u=L(e.name,e.message,c);return{type:"error",level:"error",message:e.message,timestamp:i,exception:{type:e.name,value:e.message,stacktrace:{frames:c}},fingerprint:u}}let s=String(e);if(this.shouldIgnoreError(s))return null;let a=[];return t&&a.push({filename:t,lineno:n,colno:this.config.captureColumn?r:void 0,in_app:!0}),!this.shouldIncludePath(a)||!this.shouldSample()?null:{type:"error",level:"error",message:s,timestamp:i,exception:{type:"Error",value:s,stacktrace:{frames:a}}}}destroy(){typeof window!="undefined"&&(this.originalOnError!==void 0&&(window.onerror=this.originalOnError),this.onUnhandledRejection&&window.removeEventListener("unhandledrejection",this.onUnhandledRejection),this.onResourceError&&window.removeEventListener("error",this.onResourceError,!0))}},j=H;var $=class{constructor(){this.name="performance";this.version="1.0.0";this.config={sampleRate:.1,captureLongTasks:!0,captureResources:!1,resourceSampleRate:.01,longTaskSampleRate:.05,captureNavigation:!0,captureFP:!0};this.observers=[];this.navLoadTimer=null;this.totalBlockingTime=0;this.fcpTime=null;this.lastLongTaskEndTime=0;this.ttiReported=!1;this.ttiTimer=null}setup(e){this.client=e,this.loadConfig(),this.observeWebVitals(),this.observeLongTasks(),this.observeNavigation(),this.observeResources()}loadConfig(){let e=this.client.config;e&&y(e.performance)&&(this.config={...this.config,...e.performance})}shouldSample(e="default"){var n,r,i;let t=(n=this.config.sampleRate)!=null?n:.1;return e==="resource"?t=(r=this.config.resourceSampleRate)!=null?r:.01:e==="longtask"?t=(i=this.config.longTaskSampleRate)!=null?i:.05:e==="vitals"&&(t=1),t>=1?!0:t<=0?!1:Math.random(){let n=t.getEntriesByName("first-paint");n.length>0&&this.reportMetric("FP",n[0].startTime,"ms",{},"vitals")});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeLCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries(),r=n[n.length-1];if(r){let i=r.renderTime||r.loadTime||r.startTime;this.reportMetric("LCP",i,"ms",{},"vitals")}});e.observe({type:"largest-contentful-paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeFID(){try{let e=new PerformanceObserver(t=>{let n=t.getEntries();if(n.length>0){let r=n[0],i=r.processingStart-r.startTime;this.reportMetric("FID",i,"ms",{},"vitals")}});e.observe({type:"first-input",buffered:!0}),this.observers.push(e)}catch(e){}}observeCLS(){try{let e=0,t=0,n=[],r=new PerformanceObserver(i=>{let s=i.getEntries();for(let a of s)if(!a.hadRecentInput){let c=n[0],u=n[n.length-1];t&&a.startTime-((u==null?void 0:u.duration)||0)<1e3&&a.startTime-c.startTime<5e3?(t+=a.value||0,n.push(a)):(t=a.value||0,n=[a]),t>e&&(e=t,this.reportMetric("CLS",e,"",{},"vitals"))}});r.observe({type:"layout-shift",buffered:!0}),this.observers.push(r)}catch(e){}}observeFCP(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByName("first-contentful-paint");n.length>0&&(this.fcpTime=n[0].startTime,this.lastLongTaskEndTime=n[0].startTime,this.reportMetric("FCP",n[0].startTime,"ms",{},"vitals"),this.scheduleTTI())});e.observe({type:"paint",buffered:!0}),this.observers.push(e)}catch(e){}}observeTTFB(){try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("navigation");if(n.length>0){let r=n[0],i=r.responseStart-r.requestStart;this.reportMetric("TTFB",Math.max(0,i),"ms",{},"vitals")}});e.observe({type:"navigation",buffered:!0}),this.observers.push(e)}catch(e){}}observeLongTasks(){if(this.config.captureLongTasks)try{let e=new PerformanceObserver(t=>{let n=t.getEntries();for(let r of n){let i=r.startTime+r.duration;if(i>this.lastLongTaskEndTime&&(this.lastLongTaskEndTime=i),this.fcpTime!==null&&r.startTime>=this.fcpTime){let s=r.duration-50;s>0&&(this.totalBlockingTime+=s)}this.reportMetric("longtask",r.duration,"ms",{},"longtask"),this.scheduleTTI()}});e.observe({type:"longtask",buffered:!0}),this.observers.push(e)}catch(e){}}scheduleTTI(){this.ttiReported||(this.ttiTimer&&clearTimeout(this.ttiTimer),this.ttiTimer=setTimeout(()=>{if(this.ttiReported)return;this.ttiReported=!0;let e=Math.max(this.lastLongTaskEndTime,this.fcpTime||0);this.reportMetric("TTI",e,"ms",{},"vitals"),this.reportMetric("TBT",this.totalBlockingTime,"ms",{},"vitals")},5e3))}observeNavigation(){this.config.captureNavigation&&typeof performance!="undefined"&&(this.onLoad=()=>{this.navLoadTimer=setTimeout(()=>{let e=performance.timing;if(!e)return;let t=e.navigationStart,n={dom_ready:e.domContentLoadedEventEnd-t,load_time:e.loadEventEnd-t,dns:e.domainLookupEnd-e.domainLookupStart,tcp:e.connectEnd-e.connectStart,ssl:e.secureConnectionStart>0?e.connectEnd-e.secureConnectionStart:0,ttfb:e.responseStart-e.requestStart,download:e.responseEnd-e.responseStart,dom_parse:e.domInteractive-e.responseEnd};for(let[r,i]of Object.entries(n))i>0&&this.reportMetric(r,i,"ms");this.navLoadTimer=null},0)},window.addEventListener("load",this.onLoad))}observeResources(){if(this.config.captureResources&&typeof performance!="undefined")try{let e=new PerformanceObserver(t=>{let n=t.getEntriesByType("resource");for(let r of n)r.duration>1e3&&this.reportMetric("resource_slow",r.duration,"ms",{resource_name:r.name.substring(0,200),resource_type:r.initiatorType},"resource")});e.observe({type:"resource",buffered:!0}),this.observers.push(e)}catch(e){}}getRating(e,t){let r={LCP:{good:2500,poor:4e3},FCP:{good:1800,poor:3e3},FP:{good:1800,poor:3e3},FID:{good:100,poor:300},CLS:{good:.1,poor:.25},TTFB:{good:800,poor:1800}}[e];return!r||t<=r.good?"good":t<=r.poor?"needs-improvement":"poor"}reportMetric(e,t,n,r={},i="default"){if(!this.shouldSample(i))return;let s=this.getRating(e,t),a={type:"performance",level:"info",metric:e,value:Math.round(t*100)/100,unit:n,rating:s,timestamp:d(),tags:{metric:e,rating:s,...r}};this.client.captureEvent(a)}destroy(){for(let e of this.observers)try{e.disconnect()}catch(t){}this.observers=[],this.ttiTimer&&(clearTimeout(this.ttiTimer),this.ttiTimer=null),this.navLoadTimer&&(clearTimeout(this.navLoadTimer),this.navLoadTimer=null),typeof window!="undefined"&&this.onLoad&&window.removeEventListener("load",this.onLoad)}},W=$;var X=class{constructor(){this.name="network";this.version="1.0.0";this.config={captureXHR:!0,captureFetch:!0,captureSuccess:!1,captureBody:!1,captureRequestBody:!1,captureResponseBody:!1,captureRequestHeaders:["content-type","user-agent"],captureResponseHeaders:["content-type","content-length"],errorSampleRate:1,successSampleRate:.01,slowThreshold:3e3,slowSampleRate:1,ignoreStatusCodes:[]}}setup(e){this.client=e,this.loadConfig(),this.config.captureFetch&&this.patchFetch(),this.config.captureXHR&&this.patchXHR()}loadConfig(){let e=this.client.config;e&&y(e.network)&&(this.config={...this.config,...e.network}),e.ignoreUrls&&(this.config.ignoreUrls=[...this.config.ignoreUrls||[],...e.ignoreUrls])}shouldIgnore(e){return C(e,this.config.ignoreUrls||[])}shouldSample(e,t){var r,i,s,a;let n=e?(r=this.config.errorSampleRate)!=null?r:1:(i=this.config.successSampleRate)!=null?i:.01;return!e&&t>=((s=this.config.slowThreshold)!=null?s:3e3)&&(n=(a=this.config.slowSampleRate)!=null?a:1),n>=1?!0:n<=0?!1:Math.random(){var h;let p=d()-r,w=u.ok,f=u.status;return(h=e.config.ignoreStatusCodes)!=null&&h.includes(f)||!e.shouldSample(!w,p)||(!w||e.config.captureSuccess)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,status_code:f,duration:p,success:w,error:w?void 0:`HTTP ${f}`}),u},u=>{let p=d()-r;throw e.shouldSample(!0,p)&&e.reportNetwork({sub_type:"fetch",method:s.toUpperCase(),url:a,duration:p,success:!1,error:(u==null?void 0:u.message)||"Network error"}),u}),c}}patchXHR(){if(typeof XMLHttpRequest=="undefined")return;this.originalXHROpen=XMLHttpRequest.prototype.open,this.originalXHRSend=XMLHttpRequest.prototype.send;let e=this;XMLHttpRequest.prototype.open=function(t,n){return this._lightMethod=t,this._lightUrl=typeof n=="string"?n:n.toString(),this._lightTracked=!1,e.originalXHROpen.apply(this,arguments)},XMLHttpRequest.prototype.send=function(t){let n=d(),r=this._lightMethod||"GET",i=this._lightUrl||"",s=this._lightTracked;if(e.shouldIgnore(i)||s)return e.originalXHRSend.apply(this,arguments);this._lightTracked=!0;let a=x(i),c=0;t&&typeof t=="string"&&(c=t.length);let u=this.onloadend;return this.onloadend=function(p){var v;let w=d()-n,f=this.status,h=f>=200&&f<300;if(!((v=e.config.ignoreStatusCodes)!=null&&v.includes(f))&&e.shouldSample(!h,w)&&(!h||e.config.captureSuccess)){let g=0;try{let E=this.getResponseHeader("content-length");E&&(g=parseInt(E,10)),!g&&this.responseText&&(g=this.responseText.length)}catch(E){}e.reportNetwork({sub_type:"xhr",method:r.toUpperCase(),url:a,status_code:f,duration:w,request_size:c||void 0,response_size:g||void 0,success:h,error:h?void 0:`HTTP ${f}`})}if(u)return u.call(this,p)},e.originalXHRSend.apply(this,arguments)}}reportNetwork(e){let t={type:"network",level:e.success?"info":"error",timestamp:d(),tags:{method:e.method,sub_type:e.sub_type,success:String(e.success)},...e};this.client.captureEvent(t)}destroy(){this.originalFetch&&(window.fetch=this.originalFetch),this.originalXHROpen&&(XMLHttpRequest.prototype.open=this.originalXHROpen),this.originalXHRSend&&(XMLHttpRequest.prototype.send=this.originalXHRSend)}},A=X;var K=class{constructor(){this.name="behavior";this.version="1.0.0";this.config={capturePV:!0,captureClick:!0,captureRoute:!0,captureDuration:!0,captureScroll:!0,clickThrottle:300,scrollThrottle:1e3,sampleRate:.01};this.lastClickTime=0;this.lastScrollTime=0;this.maxScrollDepth=0;this.pageEnterTime=0;this.scrollReported=!1}setup(e){this.client=e,this.loadConfig(),this.config.capturePV&&this.trackPV(),this.config.captureClick&&this.trackClick(),this.config.captureRoute&&this.trackRoute(),this.config.captureDuration&&this.trackPageDuration(),this.config.captureScroll&&this.trackScroll()}loadConfig(){let e=this.client.config;e&&y(e.behavior)&&(this.config={...this.config,...e.behavior})}shouldSample(){var t;let e=(t=this.config.sampleRate)!=null?t:.01;return e>=1?!0:e<=0?!1:Math.random(){var a,c,u;let t=d(),n=(a=this.config.clickThrottle)!=null?a:300;if(t-this.lastClickTime{let r=b();if(r!==e){let i=e,s=r;e=r,this.reportBehavior({sub_type:"route",page_url:s,referrer:i,properties:{from:i,to:s}}),this.pageEnterTime=d(),this.maxScrollDepth=0,this.scrollReported=!1}};this.originalPushState=history.pushState,this.originalReplaceState=history.replaceState;let n=this;history.pushState=function(){let r=n.originalPushState.apply(this,arguments);return setTimeout(t,0),r},history.replaceState=function(){let r=n.originalReplaceState.apply(this,arguments);return setTimeout(t,0),r},this.onPopState=()=>{setTimeout(t,0)},this.onHashchange=()=>{setTimeout(t,0)},window.addEventListener("popstate",this.onPopState),window.addEventListener("hashchange",this.onHashchange)}trackPageDuration(){if(typeof window=="undefined")return;this.pageEnterTime=d();let e=()=>{let t=d()-this.pageEnterTime;t>1e3&&(this.config.captureScroll&&!this.scrollReported&&this.reportScroll(),this.reportBehavior({sub_type:"duration",page_url:b(),properties:{duration:t,max_scroll_depth:this.maxScrollDepth}}))};this.onVisibilityChange=()=>{document.hidden?e():this.pageEnterTime=d()},this.onBeforeUnload=e,this.onPagehide=e,document.addEventListener("visibilitychange",this.onVisibilityChange),window.addEventListener("beforeunload",this.onBeforeUnload),window.addEventListener("pagehide",this.onPagehide)}trackScroll(){typeof window!="undefined"&&(this.onScroll=()=>{var r;let e=d(),t=(r=this.config.scrollThrottle)!=null?r:1e3;if(e-this.lastScrollTimethis.maxScrollDepth&&(this.maxScrollDepth=n)},window.addEventListener("scroll",this.onScroll,{passive:!0}))}reportScroll(){this.scrollReported||(this.scrollReported=!0,this.reportBehavior({sub_type:"scroll",page_url:b(),properties:{max_depth:this.maxScrollDepth}}))}reportBehavior(e){if(!this.shouldSample())return;let t={type:"behavior",level:"info",timestamp:d(),tags:{sub_type:e.sub_type,page_url:b()},...e};this.client.captureEvent(t)}destroy(){typeof window!="undefined"&&(this.originalPushState&&(history.pushState=this.originalPushState),this.originalReplaceState&&(history.replaceState=this.originalReplaceState),this.onPopState&&window.removeEventListener("popstate",this.onPopState),this.onHashchange&&window.removeEventListener("hashchange",this.onHashchange),this.onBeforeUnload&&window.removeEventListener("beforeunload",this.onBeforeUnload),this.onPagehide&&window.removeEventListener("pagehide",this.onPagehide),this.onScroll&&window.removeEventListener("scroll",this.onScroll)),typeof document!="undefined"&&(this.onClick&&document.removeEventListener("click",this.onClick,!0),this.onVisibilityChange&&document.removeEventListener("visibilitychange",this.onVisibilityChange))}},V=K;var _e="light-sentry-offline",m="events",z=1e3,Q=class{constructor(){this.name="offline";this.version="1.0.0";this.config={maxEvents:z};this.db=null;this.isOnline=!0;this.isSyncing=!1;this.pendingEvents=[]}setup(e){this.client=e,this.loadConfig(),this.initDatabase(),this.setupNetworkListeners()}loadConfig(){let e=this.client.config;e&&typeof e.offline=="object"&&(this.config={...this.config,...e.offline})}async initDatabase(){if(typeof indexedDB=="undefined"){console.warn("[LightSDK] OfflinePlugin: IndexedDB not available");return}return new Promise((e,t)=>{let n=indexedDB.open(_e,1);n.onerror=()=>{console.warn("[LightSDK] OfflinePlugin: Failed to open IndexedDB"),t(n.error)},n.onsuccess=()=>{this.db=n.result,e()},n.onupgradeneeded=r=>{let i=r.target.result,s=r.oldVersion;i.objectStoreNames.contains(m)||i.createObjectStore(m,{keyPath:"id"}).createIndex("timestamp","timestamp",{unique:!1})}})}setupNetworkListeners(){typeof window!="undefined"&&(this.isOnline=navigator.onLine,this.onOnline=()=>{console.info("[LightSDK] OfflinePlugin: Network online"),this.isOnline=!0,this.syncPendingEvents()},this.onOffline=()=>{console.info("[LightSDK] OfflinePlugin: Network offline"),this.isOnline=!1},window.addEventListener("online",this.onOnline),window.addEventListener("offline",this.onOffline))}generateId(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}async saveToIndexedDB(e){if(!this.db)return;let t=this.config.maxEvents||z,n=await this.getEventCount();return n>=t&&await this.deleteOldestEvents(n-t+1),new Promise((r,i)=>{let a=this.db.transaction([m],"readwrite").objectStore(m),c={id:this.generateId(),event:{...e,offline:!0},timestamp:d()},u=a.add(c);u.onsuccess=()=>r(),u.onerror=()=>i(u.error)})}getEventCount(){return this.db?new Promise((e,t)=>{let i=this.db.transaction([m],"readonly").objectStore(m).count();i.onsuccess=()=>e(i.result),i.onerror=()=>t(i.error)}):Promise.resolve(0)}async deleteOldestEvents(e){if(!(!this.db||e<=0))return new Promise((t,n)=>{let a=this.db.transaction([m],"readwrite").objectStore(m).index("timestamp").openCursor(),c=0;a.onsuccess=u=>{let p=u.target.result;p&&cn(a.error)})}async getAllStoredEvents(){return this.db?new Promise((e,t)=>{let s=this.db.transaction([m],"readonly").objectStore(m).index("timestamp").getAll();s.onsuccess=()=>{let a=s.result||[];a.sort((c,u)=>c.timestamp-u.timestamp),e(a)},s.onerror=()=>t(s.error)}):[]}async clearStoredEvents(){if(this.db)return new Promise((e,t)=>{let i=this.db.transaction([m],"readwrite").objectStore(m).clear();i.onsuccess=()=>e(),i.onerror=()=>t(i.error)})}async syncPendingEvents(){if(!(!this.isOnline||this.isSyncing)){this.isSyncing=!0,console.info("[LightSDK] OfflinePlugin: Syncing pending events");try{let e=0;for(;this.pendingEvents.length>0;){let n=this.pendingEvents.shift();try{this.client.captureEvent(n),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue memory event",r)}}let t=await this.getAllStoredEvents();if(t.length>0)for(let n of t)try{let r=n.event;this.client.captureEvent(r),e++}catch(r){console.error("[LightSDK] OfflinePlugin: Failed to re-enqueue event",r)}try{await this.client.flush()}catch(n){}await this.clearStoredEvents(),console.info(`[LightSDK] OfflinePlugin: Synced ${e} events`)}catch(e){console.error("[LightSDK] OfflinePlugin: Sync failed",e)}finally{this.isSyncing=!1}}}beforeReport(e){if(!this.isOnline){let n=this.config.maxEvents||z;return this.pendingEvents.length>=n&&this.pendingEvents.shift(),this.pendingEvents.push(e),this.savePendingEventsToDB(),null}let t={...e};return delete t.offline,t}savePendingEventsToDB(){if(this.pendingEvents.length===0)return;let e=this.pendingEvents.splice(0,this.pendingEvents.length),t=0,n=()=>{if(t>=e.length)return;let r=e[t];this.saveToIndexedDB(r).then(()=>{t++,n()}).catch(i=>{console.error("[LightSDK] OfflinePlugin: Failed to save event offline",i),this.pendingEvents.unshift(...e.slice(t))})};n()}destroy(){typeof window!="undefined"&&(this.onOnline&&window.removeEventListener("online",this.onOnline),this.onOffline&&window.removeEventListener("offline",this.onOffline)),this.db&&(this.db.close(),this.db=null)}},G=Q;var J=class{constructor(){this.name="sampling";this.version="1.0.0";this.config={rates:{error:1,performance:1,network:1,behavior:1}}}setup(e){this.client=e,this.loadConfig()}loadConfig(){let e=this.client.config;if(e&&y(e.sampling)){let t=e.sampling;"rates"in t&&y(t.rates)&&(this.config.rates={...this.config.rates,...t.rates})}}shouldSample(e){var r;let t=(r=this.client.config.sampleRate)!=null?r:1;if(t<=0)return!1;let n=this.getTypeRate(e);return t>=1?this.randomCheck(n):this.randomCheck(t*n)}getTypeRate(e){let t=this.config.rates||{},n=e.type;if(n==="count")return 1;let r=t[n];return r!==void 0?r:1}randomCheck(e){return e>=1?!0:e<=0?!1:Math.random()typeof i=="string"))||[];(n.includes("performance")||n.includes("all"))&&t.push(new W),(n.includes("network")||n.includes("all"))&&t.push(new A),(n.includes("behavior")||n.includes("all"))&&t.push(new V);for(let i of t)e.use(i);if(o.plugins)for(let i of o.plugins)typeof i=="object"&&i!==null&&"name"in i&&"setup"in i&&e.use(i);return e.init(),l=e,e}function ce(){return l}function ue(o){l==null||l.captureException(o)}function le(o,e){l==null||l.captureMessage(o,e)}function de(o){l==null||l.captureEvent(o)}function pe(o){l==null||l.setUser(o)}function he(o,e){l==null||l.setTag(o,e)}function fe(o){l==null||l.setTags(o)}function ge(o,e){l==null||l.setExtra(o,e)}function me(o){l==null||l.addBreadcrumb(o)}async function ve(){await(l==null?void 0:l.flush())}function ye(){l==null||l.disable()}function be(){l==null||l.enable()}var Oe={init:ae,getClient:ce,captureException:ue,captureMessage:le,captureEvent:de,setUser:pe,setTag:he,setTags:fe,setExtra:ge,addBreadcrumb:me,flush:ve,disable:ye,enable:be};return ke(De);})(); diff --git a/dist/plugins/BehaviorPlugin.d.ts b/dist/plugins/BehaviorPlugin.d.ts index 5fb85b0..be9c5f6 100644 --- a/dist/plugins/BehaviorPlugin.d.ts +++ b/dist/plugins/BehaviorPlugin.d.ts @@ -9,6 +9,15 @@ declare class BehaviorPlugin implements LightPlugin { private maxScrollDepth; private pageEnterTime; private scrollReported; + private originalPushState?; + private originalReplaceState?; + private onScroll?; + private onClick?; + private onVisibilityChange?; + private onBeforeUnload?; + private onPagehide?; + private onPopState?; + private onHashchange?; setup(client: LightClient): void; private loadConfig; private shouldSample; diff --git a/dist/plugins/ErrorPlugin.d.ts b/dist/plugins/ErrorPlugin.d.ts index f253909..cad8eba 100644 --- a/dist/plugins/ErrorPlugin.d.ts +++ b/dist/plugins/ErrorPlugin.d.ts @@ -4,15 +4,21 @@ declare class ErrorPlugin implements LightPlugin { version: string; private client; private config; + private originalOnError?; + private onUnhandledRejection?; + private onResourceError?; setup(client: LightClient): void; private loadConfig; private shouldIgnoreError; private shouldIgnoreScriptUrl; + private shouldIncludePath; + private shouldSample; + private shouldSampleResource; private setupGlobalError; private setupUnhandledRejection; + private extractUrlFromReason; private setupResourceError; private buildErrorEvent; - private parseStackTrace; destroy(): void; } export default ErrorPlugin; diff --git a/dist/plugins/OfflinePlugin.d.ts b/dist/plugins/OfflinePlugin.d.ts index 09b8e22..430d9b2 100644 --- a/dist/plugins/OfflinePlugin.d.ts +++ b/dist/plugins/OfflinePlugin.d.ts @@ -7,6 +7,8 @@ declare class OfflinePlugin implements LightPlugin { private db; private isOnline; private isSyncing; + private onOnline?; + private onOffline?; private pendingEvents; setup(client: LightClient): void; private loadConfig; @@ -14,11 +16,13 @@ declare class OfflinePlugin implements LightPlugin { private setupNetworkListeners; private generateId; private saveToIndexedDB; + private getEventCount; private deleteOldestEvents; private getAllStoredEvents; private clearStoredEvents; syncPendingEvents(): Promise; beforeReport(event: SentryEvent): SentryEvent | null; + private savePendingEventsToDB; destroy(): void; } export default OfflinePlugin; diff --git a/dist/plugins/PerformancePlugin.d.ts b/dist/plugins/PerformancePlugin.d.ts index 6f0d0de..9b41493 100644 --- a/dist/plugins/PerformancePlugin.d.ts +++ b/dist/plugins/PerformancePlugin.d.ts @@ -4,6 +4,14 @@ declare class PerformancePlugin implements LightPlugin { version: string; private client; private config; + private observers; + private onLoad?; + private navLoadTimer; + private totalBlockingTime; + private fcpTime; + private lastLongTaskEndTime; + private ttiReported; + private ttiTimer; setup(client: LightClient): void; private loadConfig; private shouldSample; @@ -15,6 +23,7 @@ declare class PerformancePlugin implements LightPlugin { private observeFCP; private observeTTFB; private observeLongTasks; + private scheduleTTI; private observeNavigation; private observeResources; private getRating; diff --git a/dist/plugins/SamplingPlugin.d.ts b/dist/plugins/SamplingPlugin.d.ts new file mode 100644 index 0000000..ae21911 --- /dev/null +++ b/dist/plugins/SamplingPlugin.d.ts @@ -0,0 +1,15 @@ +import type { LightPlugin, LightClient, SentryEvent } from '../types'; +declare class SamplingPlugin implements LightPlugin { + name: string; + version: string; + private client; + private config; + setup(client: LightClient): void; + private loadConfig; + private shouldSample; + private getTypeRate; + private randomCheck; + beforeReport(event: SentryEvent): SentryEvent | null; + destroy(): void; +} +export default SamplingPlugin; diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 09d825c..419b5b9 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -1,4 +1,9 @@ export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug'; +export interface ContextLevelConfig { + maxStackFrames: number; + maxBreadcrumbs: number; +} +export type ContextLevelMap = Record; export interface UserInfo { id?: string; username?: string; @@ -113,6 +118,15 @@ export interface DSNInfo { host: string; projectId: string; } +export interface SamplingConfig { + rates?: { + error?: number; + performance?: number; + network?: number; + behavior?: number; + [key: string]: number | undefined; + }; +} export interface LightConfig { dsn: string; release?: string; @@ -126,9 +140,12 @@ export interface LightConfig { ignoreErrors?: (string | RegExp)[]; ignoreUrls?: (string | RegExp)[]; includePaths?: (string | RegExp)[]; + maxBreadcrumbs?: number; beforeSend?: (event: SentryEvent) => SentryEvent | null; user?: UserInfo; plugins?: (LightPlugin | string)[]; + contextLevel?: Partial; + sampling?: SamplingConfig; [pluginName: string]: unknown; } export interface LightPlugin { @@ -158,4 +175,7 @@ export interface LightClient { flush(): Promise; disable(): void; enable(): void; + use(plugin: LightPlugin): void; + init(): void; + destroy(): void; } diff --git a/dist/utils/helper.d.ts b/dist/utils/helper.d.ts index 792d994..6d8541f 100644 --- a/dist/utils/helper.d.ts +++ b/dist/utils/helper.d.ts @@ -6,3 +6,4 @@ export declare function isString(value: unknown): value is string; export declare function isObject(value: unknown): value is Record; export declare function safeGet(fn: () => T, defaultValue: T): T; export declare function truncate(str: string, maxLen: number): string; +export declare function matchPatterns(value: string, patterns: (string | RegExp)[]): boolean; diff --git a/dist/utils/stacktrace.d.ts b/dist/utils/stacktrace.d.ts new file mode 100644 index 0000000..b8878b7 --- /dev/null +++ b/dist/utils/stacktrace.d.ts @@ -0,0 +1,8 @@ +import type { StackFrame } from '../types'; +export interface ParseStackOptions { + captureNodeModules?: boolean; + captureColumn?: boolean; + relativePathOnly?: boolean; + maxFrames?: number; +} +export declare function parseStackTrace(stack?: string, options?: ParseStackOptions): StackFrame[]; diff --git a/docs/00-architecture.md b/docs/00-architecture.md new file mode 100644 index 0000000..e9e5a50 --- /dev/null +++ b/docs/00-architecture.md @@ -0,0 +1,227 @@ +# Light-Sentry 轻量级前端监控系统 - 架构设计文档 + +## 一、设计理念 + +### 1.1 核心原则 + +- **轻量优先**:SDK < 10KB (gzip),服务端单节点可运行 +- **插件化架构**:核心极小,功能通过插件扩展,按需加载 +- **性能友好**:不阻塞主线程,不影响页面性能 +- **易于部署**:docker-compose up 一键启动,无需复杂运维 +- **兼容 Sentry**:接口兼容 Sentry 协议,可平滑迁移 + +### 1.2 设计目标 + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| SDK 核心体积 | < 5KB gzip | 只含事件总线和上报 | +| SDK 完整体积 | < 15KB gzip | 含错误+性能+网络插件 | +| 单节点承载 | 10万+ 事件/天 | 1C2G 服务器 | +| 部署时间 | < 5 分钟 | docker-compose 一键部署 | +| 数据延迟 | < 30 秒 | 从上报到可查询 | + +--- + +## 二、整体架构 + +### 2.1 架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 前端 SDK │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │ +│ │ Error │ │ Perf │ │ Network │ │ Behavior │ │ +│ │ Plugin │ │ Plugin │ │ Plugin │ │ Plugin │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘ │ +│ └─────────────┴─────────────┴──────────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ │ Core │ │ +│ │ (EventBus │ │ +│ │ + Queue │ │ +│ │ + Report)│ │ +│ └─────┬─────┘ │ +└──────────────────────────┼───────────────────────────────────┘ + │ HTTP (beacon / fetch / img) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Nginx 反向代理 │ +│ 限流 · CORS · 负载均衡 · SSL 卸载 │ +└──────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API 接入层 (Node.js) │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ 1. DSN 认证 (publicKey 校验) │ │ +│ │ 2. 协议解析 (envelope / store / 自定义) │ │ +│ │ 3. 数据清洗 (脱敏、过滤、去重) │ │ +│ │ 4. 错误指纹计算 │ │ +│ │ 5. 内存队列 + 批量写入 │ │ +│ └───────────────────────────────────────────────────────┘ │ +└──────────┬───────────────────────────┬──────────────────────┘ + ▼ ▼ +┌─────────────────────┐ ┌───────────────────────┐ +│ Loki (日志) │ │ MySQL (聚合数据) │ +│ - 原始错误堆栈 │ │ - 项目配置 │ +│ - 性能指标明细 │ │ - 错误聚合统计 │ +│ - 用户行为日志 │ │ - 性能指标趋势 │ +│ 保留: 7-30 天 │ │ - 告警规则 │ +└─────────────────────┘ │ - 用户管理 │ + └───────────┬───────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 查询 & 展示层 │ +│ ┌──────────────┐ ┌──────────┐ ┌──────────────────────┐ │ +│ │ 管理后台 │ │ 告警引擎 │ │ Grafana 仪表盘 │ │ +│ │ (原生 JS) │ │ (规则引擎)│ │ (复用现有工具) │ │ +│ │ - 项目管理 │ │ - 规则 │ │ - 错误趋势图 │ │ +│ │ - 错误查询 │ │ - 收敛 │ │ - 性能仪表盘 │ │ +│ │ - 性能分析 │ │ - 通知 │ │ - 自定义面板 │ │ +│ └──────────────┘ └──────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块职责 + +| 层级 | 模块 | 职责 | 技术选型 | +|------|------|------|----------| +| 采集层 | 前端 SDK | 数据采集、队列、上报 | TypeScript + 插件化 | +| 接入层 | API Server | 认证、解析、清洗、入队 | Node.js + Express | +| 存储层 | Loki | 原始日志存储 | Grafana Loki | +| 存储层 | MySQL | 聚合数据、配置、用户 | MySQL 8.0 / SQLite | +| 计算层 | 聚合服务 | 错误聚合、性能统计 | 定时任务 / 实时聚合 | +| 计算层 | 告警引擎 | 规则匹配、告警通知 | 内置规则引擎 | +| 展示层 | 管理后台 | 项目管理、数据查询 | 原生 HTML + JS | +| 展示层 | Grafana | 数据可视化 | Grafana | + +--- + +## 三、数据流 + +### 3.1 错误上报流程 + +``` +前端触发错误 + ↓ +ErrorPlugin 捕获 + ↓ +封装为标准事件格式 + ↓ +加入内存队列 + ↓ +批量/定时/页面隐藏 触发上报 + ↓ +sendBeacon / fetch / img 标签 + ↓ +Nginx (限流 + CORS) + ↓ +API Server + ├─ DSN 认证 + ├─ envelope 解析 + ├─ 数据清洗 (脱敏IP、UA) + ├─ 计算错误指纹 + └─ 写入内存队列 + ↓ +批量消费 + ├─ 写入 Loki (原始数据) + └─ 更新 MySQL 聚合表 + ↓ +Grafana / 管理后台 查询 + ↓ +告警引擎 检测规则 + ↓ +触发通知 (Webhook / 邮件) +``` + +### 3.2 性能指标流程 + +``` +PerformancePlugin 监听性能指标 + ↓ +(FP / FCP / LCP / CLS / TTI / Long Task) + ↓ +采样过滤 (默认 10% 采样率) + ↓ +批量上报 + ↓ +API Server 接收 + ├─ 数据校验 + └─ 写入 Loki + ↓ +定时聚合任务 (每 5 分钟) + ├─ 计算 P50 / P90 / P95 / P99 + ├─ 按项目 + 页面 + 小时 聚合 + └─ 写入 MySQL 性能表 + ↓ +Grafana 展示趋势图 +``` + +--- + +## 四、技术选型说明 + +### 4.1 为什么用 Loki 而不是 ES/ClickHouse? + +| 特性 | Loki | Elasticsearch | ClickHouse | +|------|------|---------------|------------| +| 部署复杂度 | 极低(单二进制) | 高(需调优) | 中 | +| 资源消耗 | 低(内存 + 磁盘) | 高(内存密集) | 中高 | +| 查询性能 | 中等(日志足够) | 高 | 极高 | +| 全文检索 | 支持(Label + grep) | 强大 | 支持 | +| Grafana 集成 | 原生 | 需配置 | 需配置 | +| 适合场景 | 日志、错误追踪 | 全文检索、复杂分析 | OLAP、大数据量 | + +**结论**:轻量级监控,Loki 足够用,运维成本最低,和 Grafana 集成最好。 + +### 4.2 为什么用 MySQL 存聚合数据? + +- 成熟稳定,运维简单 +- 聚合后数据量不大(每天几千~几万行) +- 事务支持,数据一致性好 +- 管理后台查询方便 +- 小项目甚至可以用 SQLite,零运维 + +### 4.3 为什么前端用原生 JS? + +- 后台页面不复杂,原生 JS 足够 +- 无构建流程,修改即生效 +- 体积小,加载快 +- 不依赖前端框架生态,长期维护成本低 + +--- + +## 五、与现有 Light-Sentry 的关系 + +### 5.1 现有能力 + +- ✅ Sentry 兼容接口(store / envelope) +- ✅ Loki 原始日志存储 +- ✅ Grafana 仪表盘 +- ✅ 项目管理后台 +- ✅ Docker Compose 一键部署 +- ✅ NEL (Network Error Logging) 支持 + +### 5.2 待完善能力 + +- 🚧 自研轻量 SDK +- 🚧 错误指纹聚合 +- 🚧 性能指标聚合 +- 🚧 告警引擎 +- 🚧 源 map 解析 +- 🚧 用户会话追踪 +- 🚧 行为分析插件 + +--- + +## 六、文档目录 + +``` +docs/ +├── 00-architecture.md # 本文档 - 整体架构 +├── 01-sdk-design.md # 前端 SDK 设计 +├── 02-api-server.md # 服务端接入层设计 +├── 03-data-aggregation.md # 数据聚合与存储设计 +├── 04-alert-engine.md # 告警引擎设计 +├── 05-admin-dashboard.md # 管理后台与可视化设计 +└── 06-deployment.md # 部署与运维设计 +``` diff --git a/docs/01-sdk-design.md b/docs/01-sdk-design.md new file mode 100644 index 0000000..3b31e8a --- /dev/null +++ b/docs/01-sdk-design.md @@ -0,0 +1,1245 @@ +# 前端 SDK 设计文档 + +## 一、设计原则 + +### 1.1 核心原则 + +- **极致轻量**:核心 < 5KB gzip,完整功能 < 15KB gzip +- **零侵入**:不影响业务代码,不修改原生对象原型 +- **高性能**:所有耗时操作异步化,不阻塞主线程 +- **插件化**:功能模块化,按需加载,可扩展 +- **容错性**:SDK 自身错误不影响页面运行 +- **兼容 Sentry**:DSN 格式、事件格式、上报协议兼容 + +### 1.2 体积预算 + +| 模块 | 体积 (gzip) | 说明 | +|------|-------------|------| +| Core 核心 | ~3KB | 事件总线、队列、上报、工具函数 | +| Error 插件 | ~3KB | JS 错误、Promise、资源错误 | +| Performance 插件 | ~4KB | Web Vitals、长任务、资源时序 | +| Network 插件 | ~2KB | fetch / xhr 监控 | +| Behavior 插件 | ~2KB | PV、点击、路由 | +| **完整 SDK** | **~12KB** | 核心 + 所有插件 | + +--- + +## 二、核心架构 + +### 2.1 整体结构 + +``` +┌─────────────────────────────────────────────────┐ +│ LightSDK │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Core (核心) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ +│ │ │ Config │ │ EventBus │ │ Queue │ │ │ +│ │ │ 配置管理 │ │ 事件总线 │ │ 事件队列 │ │ │ +│ │ └──────────┘ └──────────┘ └────┬────┘ │ │ +│ │ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ │ +│ │ │ Reporter │ │ Plugins │◄──────┘ │ │ +│ │ │ 上报器 │ │ 插件系统 │ │ │ +│ │ └──────────┘ └──────────┘ │ │ +│ └───────────────────────────────────────────┘ │ +│ ▲ ▲ │ +└─────────┼───────────────────┼───────────────────┘ + │ │ + ┌─────┴─────┐ ┌──────┴──────┐ + │ Plugins │ │ Hooks │ + │ (插件) │ │ (生命周期) │ + └───────────┘ └─────────────┘ +``` + +### 2.2 插件系统 + +每个插件实现标准接口: + +```typescript +interface LightPlugin { + name: string; // 插件唯一标识 + version: string; // 插件版本 + + setup(client: LightClient): void; // 安装时调用 + destroy?(): void; // 销毁时调用 + + // 可选钩子 + beforeReport?(event: Event): Event | null; // 上报前拦截 + afterReport?(event: Event): void; // 上报后回调 +} +``` + +内置插件列表: + +| 插件名 | 功能 | 体积 | 默认开启 | +|--------|------|------|----------| +| `error` | JS 错误、Promise 异常、资源加载错误 | ~3KB | 是 | +| `performance` | Web Vitals、长任务、性能指标 | ~4KB | 否 | +| `network` | fetch / xhr 请求监控 | ~2KB | 否 | +| `behavior` | PV、点击、路由、用户行为 | ~2KB | 否 | +| `sampling` | 采样控制、流量控制 | ~1KB | 是 | +| `offline` | 离线缓存、网络恢复后补发 | ~2KB | 否 | +| `breadcrumbs` | 用户行为面包屑 | ~2KB | 否 | + +--- + +## 三、核心模块详细设计 + +### 3.1 Config - 配置管理 + +#### 配置项 + +```typescript +interface LightConfig { + // ===== 基础配置 ===== + dsn: string; // DSN: https://{publicKey}@{host}/{projectId} + release?: string; // 版本号,如 1.0.0 + environment?: string; // 环境: production / development / staging + enabled?: boolean; // 是否启用,默认 true + + // ===== 上报配置 ===== + sampleRate?: number; // 全局采样率 0-1,默认 1 + maxQueueSize?: number; // 队列最大长度,默认 100 + flushInterval?: number; // 定时上报间隔(ms),默认 5000 + maxRetries?: number; // 失败重试次数,默认 3 + retryDelay?: number; // 重试基础延迟(ms),默认 1000 + + // ===== 数据配置 ===== + ignoreErrors?: (string | RegExp)[]; // 忽略的错误 + ignoreUrls?: (string | RegExp)[]; // 忽略的 URL + includePaths?: (string | RegExp)[]; // 只收集的路径 + beforeSend?: (event: Event) => Event | null; // 发送前回调 + + // ===== 用户配置 ===== + user?: { + id?: string; + username?: string; + email?: string; + [key: string]: any; + }; + + // ===== 插件配置 ===== + plugins?: (LightPlugin | string)[]; // 插件列表 + [pluginName: string]: any; // 各插件自定义配置 +} +``` + +#### DSN 解析 + +格式:`{protocol}://{publicKey}@{host}/{projectId}` + +``` +https://proj_abc123@log.example.com/1001 + │ │ │ └── projectId: 1001 + │ │ └────────────────── host: log.example.com + │ └────────────────────────────── publicKey: proj_abc123 + └──────────────────────────────────────── protocol: https +``` + +解析后: +- `projectId`: 项目 ID(纯数字,兼容 Sentry) +- `publicKey`: 认证公钥(项目内部 ID) +- `host`: 上报域名 +- `protocol`: http / https + +### 3.2 EventBus - 事件总线 + +简单的发布订阅模式,用于插件间通信: + +```typescript +class EventBus { + private handlers: Map = new Map(); + + on(event: string, handler: Function): void; // 订阅 + off(event: string, handler: Function): void; // 取消订阅 + emit(event: string, data?: any): void; // 发布 + once(event: string, handler: Function): void; // 一次性订阅 +} +``` + +内置事件: + +| 事件名 | 触发时机 | 数据 | +|--------|----------|------| +| `event` | 新事件产生 | Event 对象 | +| `report` | 事件上报前 | Event[] 数组 | +| `reported` | 上报成功 | Event[] 数组 | +| `error` | SDK 自身错误 | Error | +| `ready` | SDK 初始化完成 | - | + +### 3.3 Queue - 事件队列 + +#### 队列策略 + +``` +新事件 → 入队 → 检查是否触发上报 → 是 → 上报 + │ + └─ 否 → 等待 +``` + +触发上报的条件: +1. **队列满**:达到 `maxQueueSize` 阈值 +2. **定时**:每 `flushInterval` 毫秒 +3. **页面隐藏**:`visibilitychange` → hidden +4. **页面卸载**:`beforeunload` / `pagehide` +5. **手动调用**:`client.flush()` + +#### 事件去重 + +- 同一错误(相同指纹)1 分钟内最多上报 3 次 +- 防止死循环导致的事件风暴 + +### 3.4 Reporter - 上报器 + +#### 上报策略(优先级从高到低) + +| 方式 | 适用场景 | 优点 | 缺点 | +|------|----------|------|------| +| `navigator.sendBeacon` | 页面卸载、隐藏 | 异步、不阻塞、可靠 | 仅 POST、大小限制 64KB | +| `fetch + keepalive` | 现代浏览器 | 灵活、支持自定义 header | 不支持跨域某些场景 | +| `XMLHttpRequest` | 兼容旧浏览器 | 兼容性好 | 同步会阻塞 | +| `Image 标签` | 极端兜底 | 兼容性最好 | GET 请求、大小有限 | + +#### 上报重试 + +- 失败后指数退避重试:1s → 2s → 4s +- 最多重试 `maxRetries` 次 +- 4xx 错误不重试(客户端错误) +- 5xx / 网络错误才重试 + +#### 上报格式(兼容 Sentry Envelope) + +``` +{"event_id":"abc123","sent_at":"2024-01-01T00:00:00Z"} +{"type":"event","length":123} +{"level":"error","message":"...","exception":{...}} +{"type":"transaction","length":456} +{"transaction":"...","spans":[...]} +``` + +轻量模式下可用简化格式: +```json +{ + "events": [ + { "type": "error", "data": {...} }, + { "type": "performance", "data": {...} } + ] +} +``` + +--- + +## 四、内置插件详细设计 + +### 4.1 Error 插件(错误采集) + +#### 采集内容 + +| 错误类型 | 监听方式 | 说明 | +|----------|----------|------| +| JS 运行时错误 | `window.onerror` | 普通 JS 错误 | +| Promise 异常 | `unhandledrejection` | Promise 未捕获 | +| 资源加载错误 | `error` 事件捕获阶段 | img、script、link 等 | +| iframe 错误 | 冒泡 + postMessage | 同域 iframe | +| 框架错误 | 钩子函数(Vue/React) | 需手动接入 | + +#### 错误事件格式 + +```typescript +interface ErrorEvent { + type: 'error'; + level: 'fatal' | 'error' | 'warning' | 'info' | 'debug'; + message: string; // 错误消息 + exception?: { + type: string; // 错误类型: TypeError, ReferenceError... + value: string; // 错误消息 + stacktrace?: { + frames: StackFrame[]; // 堆栈帧,倒序(最近的调用在前) + }; + }; + timestamp: number; // 时间戳 + release?: string; // 版本 + environment?: string; // 环境 + user?: UserInfo; // 用户信息 + tags?: Record; // 标签 + extra?: Record; // 额外数据 + breadcrumbs?: Breadcrumb[]; // 面包屑 + request?: { // 请求信息 + url: string; + method?: string; + headers?: Record; + }; +} + +interface StackFrame { + filename: string; // 文件名 + function?: string; // 函数名 + lineno?: number; // 行号 + colno?: number; // 列号 + in_app?: boolean; // 是否业务代码 +} +``` + +#### 错误指纹 + +用于错误去重和聚合: +``` +指纹 = md5(错误类型 + 错误消息(脱敏后) + 堆栈前3帧的文件名+行号) +``` + +### 4.2 Performance 插件(性能采集) + +#### 采集指标 + +| 指标 | 全称 | 说明 | 采集方式 | +|------|------|------|----------| +| FP | First Paint | 首次绘制 | PerformanceObserver | +| FCP | First Contentful Paint | 首次内容绘制 | PerformanceObserver | +| LCP | Largest Contentful Paint | 最大内容绘制 | PerformanceObserver | +| CLS | Cumulative Layout Shift | 累计布局偏移 | PerformanceObserver | +| FID | First Input Delay | 首次输入延迟 | PerformanceObserver | +| TTI | Time to Interactive | 可交互时间 | 长任务 + 网络静默 | +| TBT | Total Blocking Time | 总阻塞时间 | 长任务累加 | +| Long Tasks | - | 长任务列表 | PerformanceObserver | +| Navigation Timing | - | 页面加载各阶段 | performance.timing | +| Resource Timing | - | 资源加载时序 | performance.getEntriesByType | + +#### 性能事件格式 + +```typescript +interface PerformanceEvent { + type: 'performance'; + metric: string; // 指标名: lcp, fcp, cls... + value: number; // 指标值 + unit: string; // 单位: ms, score... + rating: 'good' | 'needs-improvement' | 'poor'; // 评级 + timestamp: number; + release?: string; + environment?: string; + tags?: { + page_url: string; // 页面 URL + route?: string; // 路由路径 + [key: string]: string; + }; + extra?: Record; +} +``` + +#### 性能评级标准(Google Web Vitals) + +| 指标 | Good | Needs Improvement | Poor | +|------|------|-------------------|------| +| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s | +| FID | < 100ms | 100ms - 300ms | > 300ms | +| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 | + +#### 采样策略 + +- 错误事件:100% 采样 +- 性能指标:默认 10% 采样,可配置 +- 长任务:默认 5% 采样 +- 资源时序:默认 1% 采样 + +### 4.3 Network 插件(网络监控) + +#### 采集内容 + +- **fetch**:通过 monkey patch 包装 `window.fetch` +- **XMLHttpRequest**:通过 monkey patch 包装 `XHR.prototype.open/send` + +#### 采集字段 + +```typescript +interface NetworkEvent { + type: 'network'; + sub_type: 'fetch' | 'xhr'; + method: string; // 请求方法 + url: string; // 请求 URL + status_code?: number; // 状态码 + duration?: number; // 耗时(ms) + request_size?: number; // 请求大小 + response_size?: number; // 响应大小 + success: boolean; // 是否成功 + error?: string; // 错误信息 + timestamp: number; + release?: string; + tags?: { + route?: string; + [key: string]: string; + }; +} +``` + +#### 脱敏处理 + +- URL 中的敏感参数自动脱敏(password、token、key 等) +- 请求/响应 body 默认不上报(避免数据泄漏) +- 可配置白名单上报特定接口的 body + +### 4.4 Behavior 插件(行为采集) + +#### 采集内容 + +| 行为 | 采集方式 | 说明 | +|------|----------|------| +| PV / UV | 页面加载 + 路由变化 | 页面浏览量 | +| 点击事件 | `click` 事件委托 | 按钮、链接点击 | +| 路由变化 | History / Hash 变化 | SPA 路由跳转 | +| 停留时长 | visibilitychange + 计时 | 页面停留时间 | +| 滚动深度 | scroll 事件 + 防抖 | 页面滚动百分比 | + +#### 行为事件格式 + +```typescript +interface BehaviorEvent { + type: 'behavior'; + sub_type: 'pv' | 'click' | 'route' | 'scroll' | 'duration'; + page_url: string; + referrer?: string; + timestamp: number; + properties?: Record; // 各子类型的属性 +} +``` + +#### 点击事件属性 + +``` +- element: 元素标签名 + id + class(如 button#submit.btn-primary) +- text: 元素文本(截断前 50 字符) +- xpath: 元素 XPath(可选,默认关) +``` + +### 4.5 Sampling 插件(采样控制) + +#### 采样维度 + +- **全局采样率**:所有事件的总采样率 +- **按事件类型采样**:错误 100%,性能 10%,行为 1% +- **按用户采样**:按 user.id 哈希,稳定采样同一用户 +- **按页面采样**:重要页面 100%,普通页面 10% +- **错误突增降级**:错误率过高时自动降采样 + +#### 采样算法 + +```javascript +// 基于用户 ID 的稳定采样 +function shouldSample(userId, rate) { + const hash = murmurhash(userId || 'anonymous'); + return (hash % 10000) < (rate * 10000); +} +``` + +### 4.6 Offline 插件(离线缓存) + +- 离线时事件存入 IndexedDB +- 网络恢复后自动补发 +- 最多缓存 1000 条,超出丢弃最旧的 +- 补发时添加 `offline: true` 标记 + +### 4.7 Breadcrumbs 插件(面包屑) + +记录用户操作轨迹,错误发生时一并上报: + +```typescript +interface Breadcrumb { + type: 'navigation' | 'click' | 'http' | 'console' | 'user'; + message: string; + timestamp: number; + category?: string; + data?: Record; + level: 'info' | 'warning' | 'error'; +} +``` + +默认保留最近 20 条面包屑。 + +--- + +## 五、SDK API + +### 5.1 初始化 + +```javascript +import LightSDK from 'light-sentry'; + +LightSDK.init({ + dsn: 'https://proj_abc123@log.example.com/1001', + release: '1.0.0', + environment: 'production', + sampleRate: 1, + + // 插件配置 + plugins: ['error', 'performance', 'network'], + + // 性能插件配置 + performance: { + sampleRate: 0.1, + captureLongTasks: true, + }, + + // 网络插件配置 + network: { + ignoreUrls: ['/health', '/metrics'], + captureBody: false, + }, + + // 发送前回调 + beforeSend(event) { + // 可修改或过滤事件 + if (event.message.includes('ignore')) return null; + return event; + }, +}); +``` + +### 5.2 手动 API + +```javascript +// 捕获错误 +LightSDK.captureException(error); +LightSDK.captureMessage('Something happened', 'warning'); + +// 设置用户 +LightSDK.setUser({ id: '123', username: 'test' }); + +// 设置标签 +LightSDK.setTag('page', 'home'); +LightSDK.setTags({ platform: 'ios', version: '2.0' }); + +// 添加上下文 +LightSDK.setExtra('order_id', '12345'); + +// 添加面包屑 +LightSDK.addBreadcrumb({ + type: 'user', + message: 'User clicked button', + category: 'action', +}); + +// 手动上报 +LightSDK.captureEvent({ + type: 'custom', + message: 'custom event', + level: 'info', + extra: { foo: 'bar' }, +}); + +// 强制刷新队列 +LightSDK.flush(); + +// 禁用 / 启用 +LightSDK.disable(); +LightSDK.enable(); +``` + +### 5.3 框架集成 + +#### Vue 2 + +```javascript +import LightSDK from 'light-sentry'; +import { VueIntegration } from 'light-sentry/vue'; + +LightSDK.init({ + dsn: '...', + plugins: [new VueIntegration(Vue)], +}); +``` + +#### Vue 3 + +```javascript +app.use(LightSDK.vuePlugin, { dsn: '...' }); +``` + +#### React + +```javascript +// 错误边界 +import { ErrorBoundary } from 'light-sentry/react'; + +出错了

}> + +
+``` + +--- + +## 六、性能优化 + +### 6.1 不影响页面性能的设计 + +1. **全部异步**:SDK 初始化、事件处理、上报全异步 +2. **requestIdleCallback**:非紧急操作放在空闲时间 +3. **防抖节流**:scroll、resize 等高频事件防抖 +4. **不修改原型**:用包装方式(fetch/xhr),不污染原型 +5. **微任务批量**:同一批事件合并处理,减少事件循环开销 +6. **Web Worker**:可选,重计算(指纹、聚合)放到 Worker + +### 6.2 数据体积优化 + +这是轻量 SDK 与 Sentry 的核心区别之一——通过策略大幅减少上报数据量。 + +#### 6.2.1 错误堆栈精简 + +| 项目 | Sentry 默认 | 轻量方案 | 节省 | +|------|------------|----------|------| +| 堆栈深度 | 50 帧 | **3-5 帧**(in_app) | ~80% | +| node_modules 帧 | 保留 | **过滤掉** | ~50% | +| 列号 | 保留 | 可选,默认开 | - | +| 完整文件名 | 完整 URL | **只留相对路径** | ~30% | + +**为什么 3-5 帧就够了?** +- 90% 的错误,看前 3 帧就能定位问题 +- 真正需要深度堆栈的错误很少,遇到了再开完整模式 +- 业务代码的调用链通常不深 + +**配置方式**: +```javascript +LightSDK.init({ + error: { + maxStackFrames: 5, // 最多 5 帧 + captureNodeModules: false, // 不上报 node_modules 里的帧 + captureColumn: true, // 保留列号 + relativePathOnly: true, // 只保留相对路径,去掉域名 + } +}); +``` + +**堆栈对比**: + +Sentry(2KB+): +``` +at XMLHttpRequest.onloadend (http://localhost:5173/node_modules/.vite/deps/axios.js?v=6b332a51:1845:7) +at settle (http://localhost:5173/node_modules/.vite/deps/axios.js?v=6b332a51:1441:12) +at http://localhost:5173/node_modules/.vite/deps/axios.js?v=6b332a51:1245:12 +at wrapFn (http://localhost:5173/node_modules/.vite/deps/axios.js?v=6b332a51:2312:45) +at fetchUser (http://localhost:5173/src/api/user.js:23:15) +at UserList.vue:45:8 +...(还有 40 多帧) +``` + +轻量版(~200B): +``` +at fetchUser (src/api/user.js:23) +at UserList.vue:45 +``` + +#### 6.2.2 环境信息结构化(browser / os / device) + +**问题**:每条事件都带完整的环境信息,数据冗余严重。 + +``` +// 每条都带:~200 bytes +"browser": { "name": "Chrome", "version": "120.0.0.0" }, +"os": { "name": "Mac OS X", "version": "10.15.7" }, +"device": { "family": "MacBook Pro", "model": "MacBook Pro" } +``` + +**优化策略:三级方案** + +##### 方案 1:字典编码(SDK 端,最简单) + +把常用的浏览器/OS 映射成短编码,减少传输体积。 + +```javascript +// 内置字典 +const BROWSER_MAP = { + 'Chrome': 'c', + 'Safari': 's', + 'Firefox': 'f', + 'Edge': 'e', + 'Mobile Chrome': 'cm', + 'Mobile Safari': 'sm', + // ... +}; + +const OS_MAP = { + 'Windows': 'w', + 'Mac OS X': 'm', + 'Linux': 'l', + 'iOS': 'i', + 'Android': 'a', + // ... +}; + +// 上报时用编码 +{ + "env": { + "b": "c", // browser = Chrome + "bv": "120", // 只留主版本号 + "os": "m", // os = Mac OS X + "osv": "15", // 只留大版本 + "dv": "mbp" // device = MacBook Pro + } +} +``` + +**效果**:环境信息从 ~200B → ~50B,减少 75%。 + +##### 方案 2:会话级共享(进一步优化) + +同一个用户同一次会话里,环境信息是不变的,不需要每条都带。 + +``` +┌─────────────────────────────────────────┐ +│ 第一次上报:带完整环境信息 + session_id │ +│ 后续上报:只带 session_id │ +└─────────────────────────────────────────┘ +``` + +服务端根据 `session_id` 关联环境信息。 + +**效果**:每条事件节省 50-100B,百万级事件能省几十 GB。 + +##### 方案 3:服务端 UA 解析(最极致) + +SDK 完全不解析 UA,只上报原始 User-Agent 字符串,服务端统一解析后写入维度表。 + +``` +SDK 上报:只带 1 个 ua 字段(原始 UA 字符串) +服务端:解析后存入维度表,日志里只存维度 ID +``` + +| 维度 | 说明 | +|------|------| +| 优点 | SDK 更小(不用带 UA 解析库),解析逻辑集中维护 | +| 缺点 | 服务端多了一层解析开销 | + +**推荐组合**:SDK 做字典编码 + 只保留主版本号 → 简单、够用、SDK 体积增加很少。 + +#### 6.2.3 上下文信息分层策略 + +不是所有事件都需要完整上下文,按级别区分: + +| 事件级别 | 堆栈 | 环境信息 | breadcrumbs | request 信息 | 用户信息 | +|----------|------|----------|-------------|-------------|----------| +| fatal | 5 帧 | 完整 | 20 条 | 完整 | 完整 | +| error | 5 帧 | 精简 | 10 条 | URL+状态码 | id 即可 | +| warning | 3 帧 | 精简 | 5 条 | URL 即可 | id 即可 | +| info | 无 | 编码 | 无 | 无 | 无 | +| debug | 无 | 无 | 无 | 无 | 无 | + +**配置方式**: +```javascript +LightSDK.init({ + contextLevel: { + fatal: 'full', + error: 'standard', + warning: 'minimal', + info: 'none', + } +}); +``` + +#### 6.2.4 批量上报共享元数据 + +同批次的事件,共享相同的元数据(release、environment、user 等),不用每条都重复带。 + +**Sentry 方式(每条都带)**: +``` +event1: { release: "1.0.0", environment: "prod", user: {...}, ... } +event2: { release: "1.0.0", environment: "prod", user: {...}, ... } +event3: { release: "1.0.0", environment: "prod", user: {...}, ... } +``` + +**轻量方式(共享头)**: +```json +{ + "meta": { + "release": "1.0.0", + "environment": "prod", + "user": { "id": "123" }, + "env": { "b": "c", "os": "m" } + }, + "events": [ + { "type": "error", "message": "...", "stack": [...] }, + { "type": "performance", "metric": "lcp", "value": 2500 }, + { "type": "error", "message": "...", "stack": [...] } + ] +} +``` + +**效果**:批量 10 条的话,元数据部分减少约 90%。 + +#### 6.2.5 业务错误智能过滤 + +401、403、表单校验失败这类「预期内的业务错误」,不需要完整上报: + +```javascript +network: { + // 忽略的 HTTP 状态码(业务预期错误) + ignoreStatusCodes: [401, 403, 422, 404], + + // 错误请求只上报精简信息(URL + 状态码 + 耗时) + errorRequestDetail: 'minimal', + + // 成功请求完全不上报 + captureSuccess: false, + + // 只上报 5xx 和网络错误为「错误事件」 + // 4xx 只计数,不上报详情 +} +``` + +**对比**: +| 方式 | 一个 401 错误上报体积 | +|------|---------------------| +| Sentry | ~2-3 KB(完整堆栈+上下文) | +| 轻量版(标准) | ~200B(堆栈裁剪+精简) | +| 轻量版(业务错误过滤) | ~50B(只计数)甚至 0B(忽略) | + +#### 6.2.6 请求头 / 响应头精简 + +请求头和响应头是「隐藏的体积大户」——Sentry 默认会带一堆 headers,一对请求响应头就能有 1-2KB。 + +**问题**: +```json +// Sentry 默认带上的 headers(节选,完整有 20+ 个) +{ + "request": { + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...", + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Referer": "https://example.com/page", + "Origin": "https://example.com", + "Connection": "keep-alive", + "X-Request-Id": "abc123...", + "Authorization": "Bearer xxx..." // ⚠️ 敏感信息! + } + } +} +``` + +**核心策略:白名单 + 编码 + 不上报 body** + +##### 策略 1:白名单机制(默认只带必要的) + +不是所有 header 都有价值,默认只上报对排查问题有用的几个: + +```javascript +network: { + // 请求头白名单(默认) + captureRequestHeaders: [ + 'content-type', + 'user-agent', // 或者干脆不带,环境信息里已有 + 'x-request-id', + ], + + // 响应头白名单(默认) + captureResponseHeaders: [ + 'content-type', + 'content-length', + 'x-request-id', + 'server', + ], + + // ❌ 绝对禁止上报的 header(黑名单,防止敏感信息泄露) + forbiddenHeaders: [ + 'authorization', + 'cookie', + 'set-cookie', + 'token', + 'api-key', + 'x-api-key', + 'x-auth-token', + ], +} +``` + +**效果**:从 20+ 个 header → 3-5 个,体积减少 80%+。 + +##### 策略 2:Header 名称短编码 + +把常见的 header 名称映射成短编码,进一步减少体积。 + +```javascript +const HEADER_NAME_MAP = { + 'content-type': 'ct', + 'user-agent': 'ua', + 'x-request-id': 'rid', + 'content-length': 'cl', + 'server': 'sv', + 'accept': 'ac', + 'referer': 'ref', + 'origin': 'org', + // ... +}; +``` + +**编码前后对比**: +```json +// 编码前(~150B) +{ "headers": { "content-type": "application/json", "x-request-id": "abc123" } } + +// 编码后(~70B) +{ "h": { "ct": "application/json", "rid": "abc123" } } +``` + +##### 策略 3:Header 值字典化 + +对于值相对固定的 header,也可以做字典映射: + +```javascript +// Content-Type 的常见值 +const CONTENT_TYPE_MAP = { + 'application/json': '1', + 'text/html': '2', + 'text/plain': '3', + 'application/x-www-form-urlencoded': '4', + 'multipart/form-data': '5', + 'image/png': '6', + // ... +}; +``` + +**效果**:每个 header 值从十几个字符 → 1-2 个字符。 + +##### 策略 4:body 默认不上报 + +请求体和响应体是最大的体积来源,而且: +- 可能包含敏感数据(密码、token、用户信息) +- 体积大(几 KB 到几 MB 都有可能) +- 大部分场景下,排查问题不需要看 body + +```javascript +network: { + // ❌ 默认不上报请求/响应体 + captureRequestBody: false, + captureResponseBody: false, + + // (可选)只对特定接口上报 body,且做脱敏 + captureBodyForUrls: [ + '/api/debug/*', + ], + + // body 大小限制(即使开启也截断) + maxBodySize: 2048, // 最多 2KB +} +``` + +#### 6.2.7 进一步精简:协议层与传输层 + +前面的优化都是「内容层面」的精简,协议和传输层面还有很大空间。 + +##### 优化 1:全量字段短编码 + +不只是 header,**所有字段名**都可以用短编码,减少 JSON key 的重复开销。 + +```javascript +// 字段名映射表 +const FIELD_MAP = { + // 通用字段 + 'type': 't', + 'level': 'l', + 'message': 'm', + 'timestamp': 'ts', + 'release': 'r', + 'environment': 'e', + 'platform': 'p', + + // 错误相关 + 'exception': 'ex', + 'stacktrace': 'st', + 'frames': 'fr', + 'filename': 'fn', + 'function': 'fc', + 'lineno': 'ln', + 'colno': 'cn', + 'in_app': 'ia', + + // 用户 + 'user': 'u', + 'user.id': 'uid', + + // 标签/扩展 + 'tags': 'tg', + 'extra': 'xt', + + // 请求 + 'request': 'req', + 'url': 'u', + 'headers': 'h', + + // 性能 + 'metric': 'mt', + 'value': 'v', + 'unit': 'un', + 'rating': 'rt', +}; +``` + +**编码前后对比**: +```json +// 编码前(~120B) +{ + "type": "error", + "level": "error", + "message": "Something broke", + "timestamp": 1704067200000, + "release": "1.0.0" +} + +// 编码后(~65B) +{ + "t": "e", + "l": "e", + "m": "Something broke", + "ts": 1704067200000, + "r": "1.0.0" +} +``` + +**效果**:字段名部分减少 50-60%。 + +##### 优化 2:时间戳相对化 + +同一批事件的时间戳都很接近,用「相对于批次起始时间」的差值表示,数字更小、字符更少。 + +```json +// 传统方式(每条都是完整时间戳) +{ + "events": [ + { "ts": 1704067200123, ... }, + { "ts": 1704067200456, ... }, + { "ts": 1704067200789, ... } + ] +} + +// 相对时间戳(批次头存基准时间,每条存差值) +{ + "base_ts": 1704067200000, + "events": [ + { "ts": 123, ... }, + { "ts": 456, ... }, + { "ts": 789, ... } + ] +} +``` + +**效果**:每条事件时间戳从 13 位数字 → 2-4 位,减少 60-80%。 + +##### 优化 3:批量内字符串去重 + +同一批事件里,很多字符串是重复的(同一页面、同一浏览器、同一版本),用「字符串表 + 索引」的方式。 + +```json +// 传统方式(每条都带完整字符串) +{ + "events": [ + { "page": "/home", "release": "1.0.0", "browser": "Chrome" }, + { "page": "/home", "release": "1.0.0", "browser": "Chrome" }, + { "page": "/about", "release": "1.0.0", "browser": "Safari" } + ] +} + +// 字符串表方式(共享字符串,只用索引引用) +{ + "strings": ["/home", "1.0.0", "Chrome", "/about", "Safari"], + "events": [ + { "page": 0, "release": 1, "browser": 2 }, + { "page": 0, "release": 1, "browser": 2 }, + { "page": 3, "release": 1, "browser": 4 } + ] +} +``` + +**效果**:批量越大、重复越多,节省越明显。10 条以上批量时,减少 30-50%。 + +##### 优化 4:二进制协议替代 JSON + +JSON 是文本格式,冗余度很高。用二进制协议可以进一步压缩: + +| 协议 | 体积 | 解析速度 | 可读性 | +|------|------|----------|--------| +| JSON | 100%(基准) | 慢 | 好 | +| MessagePack | 约 60-70% | 快 | 差(需工具解码) | +| Protobuf | 约 40-50% | 最快 | 最差 | +| 自定义二进制 | 约 30-40% | 快 | 最差 | + +**推荐策略**: +- 默认用 JSON(简单、调试方便) +- 高性能模式下可选 MessagePack +- 一般项目 JSON 足够,二进制增加复杂度收益不一定高 + +##### 优化 5:智能采样(不是全量 10%,而是动态调整) + +不是所有事件都值得同样的采样率,按「价值」动态调整: + +```javascript +sampling: { + // 错误事件:100% 采样 + error: 1.0, + + // 性能指标:默认 10%,但 LCP > 4s 的 100% 采样(慢的全要) + performance: { + default: 0.1, + poor: 1.0, // 评级为 poor 的全采 + needsImprovement: 0.5, + good: 0.05, + }, + + // 网络请求:错误 100%,成功 1% + network: { + error: 1.0, + success: 0.01, + // 慢请求也全采 + slowThreshold: 3000, + slow: 1.0, + }, + + // 同一个用户全采或全不采(保证会话完整) + userBased: true, +} +``` + +**为什么智能采样更好?** +- 「好的」性能数据 99% 都是正常的,采多了没用 +- 「差的」性能数据才是我们真正想看的,要全采 +- 同样,成功的请求采 1% 就够统计了,失败的才需要详细排查 + +**效果**:总体数据量再减 50-70%,同时保证「有价值的数据」不丢。 + +##### 优化 6:相同错误聚合上报(最狠的一招) + +同一个错误 1 分钟内出现 100 次,没必要上报 100 次完整堆栈。 + +``` +第 1 次:上报完整信息(堆栈 + 上下文) +第 2~N 次:只上报计数 + 时间戳(几十字节) +``` + +```json +{ + "events": [ + // 完整错误(首次出现) + { + "t": "e", + "m": "Cannot read property 'foo' of undefined", + "st": [...], + "full": true + }, + // 聚合计数(后续同指纹错误) + { + "fp": "a1b2c3d4", // 错误指纹 + "cnt": 15, // 15 次 + "ts_start": 123, // 起始时间偏移 + "ts_end": 456 // 结束结束 + } + ] +} +``` + +**效果**:对于高频错误,减少 90%+ 的上报量。 + +#### 6.2.8 传输层优化 + +数据体积之外,传输效率也很重要: + +##### 优化 1:gzip 压缩 + +所有上报请求开启 gzip 压缩: +- JSON 数据压缩率通常有 60-80% +- 体积从 300B → 60-120B +- Nginx 层自动解压或直接透传 + +##### 优化 2:HTTP 连接复用 + +- 开启 `keep-alive`,复用 TCP 连接 +- 减少 TCP 握手开销 +- 用同一个连接发多批数据 + +##### 优化 3:HTTP/2 + +- 多路复用,多个请求并行 +- 头部压缩(HPACK) +- 服务端支持的话自动启用 + +##### 优化 4:上报时机优化 + +- 尽量在网络空闲时上报(`navigator.onLine` + `requestIdleCallback`) +- 页面加载高峰期不上报,避免影响页面性能 +- 网络从离线变在线时,补发离线缓存的数据 + +#### 6.2.9 服务端二次精简 + +SDK 已经精简过了,服务端入库前还可以再来一刀: + +1. **字段过滤**:再次检查,去掉不需要的字段 +2. **堆栈再裁剪**:生产环境再裁掉几帧 +3. **敏感信息二次脱敏**:防止 SDK 端漏网之鱼 +4. **采样降频**:超过配额的项目,服务端再降采样 +5. **冷热分层**:热数据(7天)存完整信息,冷数据(30天+)只存聚合统计 + +--- + +#### 6.2.10 体积优化总览 + +| 优化项 | Sentry 基线 | 优化后 | 减少比例 | +|--------|------------|--------|---------| +| 堆栈深度 | 50 帧 | 5 帧 | 80% | +| node_modules 帧 | 保留 | 过滤 | 50% | +| 环境信息 | 完整明细 | 字典编码 + 维度表 | 75-95% | +| 请求头/响应头 | 20+ 个全量 | 白名单 + 短编码 | 80-90% | +| 请求/响应 body | 默认带上 | 默认不上报 | 100% | +| 字段名 | 完整名称 | 短编码(t/l/m/ts...) | 50-60% | +| 时间戳 | 完整 13 位 | 相对时间戳 | 60-80% | +| 批量元数据 | 每条都带 | 共享头 + 字符串表 | 70-90% | +| 业务错误 | 完整上报 | 只计数/忽略 | 80-100% | +| 相同错误 | 每条都完整上报 | 第1条完整 + 其余计数 | 90%+ | +| 采样策略 | 固定采样率 | 智能采样(按价值) | 50-70% | +| 传输压缩 | 无 | gzip 压缩 | 60-80% | +| **综合(普通错误,gzip前)** | **~3KB/条** | **~100-200B/条** | **93-97%** | +| **综合(普通错误,gzip后)** | **~1KB/条** | **~20-50B/条** | **95-98%** | + +--- + +### 6.3 错误安全 + +- SDK 自身 try-catch 包裹,错误不抛出 +- 无限循环检测:相同错误 1 秒内 > 10 次自动暂停 +- 递归保护:防止 SDK 错误触发新的 SDK 错误 + +--- + +## 七、与 Sentry SDK 的兼容性 + +### 7.1 兼容的部分 + +- DSN 格式完全一致 +- 事件数据结构兼容(exception、stacktrace、user、tags 等) +- envelope 上报格式兼容 +- API 风格类似(captureException、captureMessage、setUser 等) + +### 7.2 不兼容的部分 + +- 没有 Scope 概念(简化设计) +- 没有 Hub 概念(单实例设计) +- 没有 Integration 概念(用 Plugin 替代) +- 不支持完整的分布式追踪(轻量版) +- 不支持 Session/Replay(可插件扩展) + +### 7.3 迁移指南 + +从 Sentry 迁移到 Light-Sentry: + +```diff +- import * as Sentry from '@sentry/browser'; ++ import LightSDK from 'light-sentry'; + +- Sentry.init({ dsn: '...', ... }); ++ LightSDK.init({ dsn: '...', ... }); + + // API 基本一致 +- Sentry.captureException(err); ++ LightSDK.captureException(err); + +- Sentry.setUser({ id: '1' }); ++ LightSDK.setUser({ id: '1' }); +``` diff --git a/docs/02-api-server.md b/docs/02-api-server.md new file mode 100644 index 0000000..2a16acc --- /dev/null +++ b/docs/02-api-server.md @@ -0,0 +1,492 @@ +# 服务端接入层设计文档 + +## 一、设计原则 + +### 1.1 核心原则 + +- **高并发**:单节点支撑 1000+ QPS 事件上报 +- **低延迟**:接口响应 < 50ms(先入队再处理) +- **高可用**:进程崩溃不丢数据(本地持久化队列) +- **可扩展**:支持横向扩展,无状态设计 +- **兼容性**:完全兼容 Sentry 协议,SDK 可无缝切换 + +### 1.2 性能指标 + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| 单节点 QPS | 1000+ | 4C8G 服务器 | +| P99 响应时间 | < 50ms | 99% 请求在 50ms 内返回 | +| 数据丢失率 | < 0.01% | 异常情况下的数据丢失率 | +| 单事件处理耗时 | < 1ms | 从入队到写入存储 | + +--- + +## 二、API 接口设计 + +### 2.1 接口列表 + +| 端点 | 方法 | 功能 | 优先级 | +|------|------|------|--------| +| `/api/{projectId}/envelope/` | POST | Envelope 批量上报 | 最高 | +| `/api/{projectId}/store/` | POST | 单事件上报 | 高 | +| `/api/{projectId}/nel/` | POST | NEL 网络错误上报 | 中 | +| `/api/{projectId}/health` | GET | 健康检查 | - | +| `/api/projects/` | GET/POST | 项目管理 | - | +| `/api/projects/{id}` | GET/PUT/DELETE | 项目 CRUD | - | + +### 2.2 Envelope 接口(主要上报接口) + +#### URL 格式 + +``` +POST /api/{projectId}/envelope/?sentry_key={publicKey}&sentry_version=7 +``` + +#### 请求头 + +| Header | 说明 | 必填 | +|--------|------|------| +| `Content-Type` | `application/x-sentry-envelope` 或 `application/json` | 是 | +| `X-Sentry-Auth` | Sentry 认证头(备用方式) | 否 | + +#### 认证方式(二选一) + +**方式 1:URL 参数(推荐,兼容性好)** +``` +?sentry_key={publicKey}&sentry_version=7 +``` + +**方式 2:Header 方式** +``` +X-Sentry-Auth: Sentry sentry_version=7, sentry_key={publicKey} +``` + +#### Envelope 格式解析 + +标准 Envelope 格式: +``` +# 第 1 行:envelope header(JSON) +{"event_id":"abc123","sent_at":"2024-01-01T00:00:00Z"} +# 第 2 行:item header(JSON) +{"type":"event","length":123} +# 第 3 行:item payload(JSON,长度 = length) +{"level":"error","message":"test",...} +# 第 4 行:下一个 item header +{"type":"transaction","length":456} +# ... +``` + +支持的 item 类型: + +| type | 说明 | 处理方式 | +|------|------|----------| +| `event` | 错误事件 | 完整处理 + 写入 Loki + 聚合 | +| `transaction` | 性能事务 | 提取关键指标 + 聚合 | +| `session` | 会话 | 计数 + 聚合 | +| `attachment` | 附件 | 忽略(轻量版不支持) | +| `profile` | 性能剖析 | 忽略 | +| `statsd` | 客户端统计 | 忽略 | +| `user_report` | 用户反馈 | 存储 + 计数 | + +#### 简化格式(自研 SDK 专用) + +为了减少解析开销,自研 SDK 可使用简化格式: + +```json +{ + "events": [ + { + "type": "error", + "data": { ... } + }, + { + "type": "performance", + "data": { ... } + } + ] +} +``` + +Content-Type: `application/json` + +### 2.3 Store 接口(兼容旧版 SDK) + +``` +POST /api/{projectId}/store/?sentry_key={publicKey} +Content-Type: application/json + +{ + "event_id": "abc123", + "level": "error", + "message": "...", + "exception": { ... } +} +``` + +### 2.4 NEL 接口(Network Error Logging) + +``` +POST /api/{projectId}/nel/ +Content-Type: application/reports+json + +[ + { + "age": 123, + "type": "network-error", + "url": "https://example.com/", + "body": { + "sampling_fraction": 1.0, + "server_ip": "1.2.3.4", + "protocol": "h2", + "method": "GET", + "status_code": 0, + "elapsed_time": 123, + "type": "dns.failed" + } + } +] +``` + +--- + +## 三、处理流程 + +### 3.1 整体流程 + +``` +HTTP 请求到达 + ↓ +Nginx (限流 + CORS + 日志) + ↓ +1. 认证中间件 + ├─ 解析 projectId (URL) + ├─ 解析 publicKey (query / header) + └─ 校验项目是否存在 + ↓ +2. 请求体解析 + ├─ 根据 Content-Type 选择解析器 + ├─ envelope 格式 → 逐行解析 + └─ json 格式 → JSON.parse + ↓ +3. 数据清洗 + ├─ 字段校验 + ├─ 敏感数据脱敏 + ├─ 计算错误指纹 + └─ 补充默认字段 + ↓ +4. 写入内存队列(立即返回响应) + ↓ +5. 异步批量消费 + ├─ 写入 Loki(原始日志) + ├─ 更新 MySQL 聚合表 + └─ 触发告警检测 +``` + +### 3.2 认证中间件 + +```javascript +async function sentryAuth(req, res, next) { + // 1. 从 URL 中提取 projectId + const projectId = req.params.projectId; + + // 2. 从 URL 参数或 Header 获取 publicKey + const publicKey = req.query.sentry_key || + parseSentryAuthHeader(req.headers['x-sentry-auth']); + + // 3. 校验项目是否存在 + const project = projectStore.findByProjectId(projectId); + if (!project) { + return res.status(404).json({ detail: 'Project not found' }); + } + + // 4. 校验 publicKey + if (project.publicKey !== publicKey) { + return res.status(401).json({ detail: 'Invalid public key' }); + } + + // 5. 挂载到请求对象 + req.project = project; + next(); +} +``` + +### 3.3 中间件顺序(重要) + +``` +express.raw() ← Sentry 路由先挂载,避免 express.json() 干扰 + ↓ +sentryAuth ← DSN 认证 + ↓ +envelopeParser ← 解析 envelope 格式 + ↓ +rateLimit ← 限流 + ↓ +handler ← 业务处理 + +express.json() ← 其他 API 路由 + ↓ +其他业务中间件 +``` + +**关键设计**:Sentry 上报接口必须在 `express.json()` 之前挂载,否则 raw body 会被解析成 JSON,导致 envelope 格式解析失败。 + +--- + +## 四、内存队列设计 + +### 4.1 队列结构 + +``` +┌─────────────────────────────────────────┐ +│ 内存队列 (Array) │ +│ [ evt1, evt2, evt3, ..., evtN ] │ +│ ↑ ↑ │ +│ head(tail) maxSize │ +└─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ 批量消费定时器 │ + │ 每 1s 或满 100 条 │ + └─────────┬───────────┘ + │ + ▼ + ┌─────────────────────┐ + │ 批量写入 Loki │ + │ 批量更新 MySQL │ + │ 告警检测 │ + └─────────────────────┘ +``` + +### 4.2 队列参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `maxQueueSize` | 10000 | 队列最大长度 | +| `flushInterval` | 1000ms | 定时刷间隔 | +| `batchSize` | 100 | 每批处理数量 | +| `persistInterval` | 5000ms | 持久化间隔(防止丢数据) | + +### 4.3 队列满时的策略 + +1. **新事件丢弃最旧的**:FIFO,保证新事件优先 +2. **采样降级**:队列 > 80% 时,非错误事件自动降采样 +3. **快速失败**:队列 > 95% 时,直接返回 429 +4. **本地持久化**:优雅关闭时刷入本地文件 + +### 4.4 本地持久化(可选) + +防止进程崩溃丢数据: + +- 使用 LevelDB / SQLite 作为持久化队列 +- 入队时先写磁盘,再读内存 +- 启动时从磁盘恢复未处理的事件 +- 性能影响:QPS 从 1000+ 降到 ~500 + +--- + +## 五、数据清洗与脱敏 + +### 5.1 字段校验 + +| 字段 | 校验规则 | 不通过处理 | +|------|----------|-----------| +| `event_id` | 32 位 hex,可选 | 自动生成 | +| `timestamp` | ISO 时间戳,可选 | 用服务器时间 | +| `message` | 长度 < 8KB | 截断 | +| `exception.stacktrace` | 深度 < 50 帧 | 截断 | +| `breadcrumbs` | 数量 < 100 | 截断 | +| `extra` | 大小 < 16KB | 截断 | +| 总大小 | < 256KB | 拒绝 | + +### 5.2 敏感数据脱敏 + +自动脱敏的字段: + +| 字段名(不区分大小写) | 脱敏方式 | +|----------------------|----------| +| `password`, `passwd`, `pwd` | `***` | +| `token`, `access_token`, `refresh_token` | 前 4 位 + `***` | +| `secret`, `api_key`, `apikey` | `***` | +| `email` | `a***@b.com` | +| `phone`, `mobile` | `138****1234` | +| `id_card`, `idcard` | `110***********1234` | +| `credit_card`, `card_no` | `6222**********1234` | +| IP 地址 | 保留前两段(192.168.x.x) | + +### 5.3 错误指纹计算 + +用于错误去重和聚合: + +```javascript +function computeFingerprint(event) { + const { exception } = event; + if (!exception) { + return md5(event.message || 'unknown'); + } + + // 提取关键信息 + const type = exception.type || 'Error'; + const message = normalizeMessage(exception.value); // 脱敏 + 去变量 + const frames = exception.stacktrace?.frames || []; + + // 取前 3 个 in_app 栈帧 + const keyFrames = frames + .filter(f => f.in_app !== false) + .slice(0, 3) + .map(f => `${f.filename}:${f.lineno}`); + + return md5(`${type}:${message}:${keyFrames.join('|')}`); +} +``` + +--- + +## 六、限流设计 + +### 6.1 限流维度 + +| 维度 | 默认限制 | 说明 | +|------|----------|------| +| 每项目每秒 | 100 条 | 项目级 QPS 限制 | +| 每项目每天 | 100000 条 | 项目级日配额 | +| 每 IP 每秒 | 50 条 | IP 级 QPS 限制 | +| 全局限流 | 1000 QPS | 服务器总 QPS | + +### 6.2 限流算法 + +- **令牌桶算法**:QPS 限流用令牌桶 +- **滑动窗口**:日配额用滑动窗口 +- **内存计数**:单节点足够,分布式需 Redis + +### 6.3 超限处理 + +| 超出比例 | 处理方式 | +|----------|----------| +| < 80% | 正常处理 | +| 80% - 100% | 非错误事件降采样 50% | +| 100% - 150% | 只保留错误事件,其余丢弃 | +| > 150% | 全部丢弃,返回 429 | + +--- + +## 七、批量消费设计 + +### 7.1 消费流程 + +``` +批次事件 + ↓ +按项目分组 + ↓ +按事件类型分组 (error / performance / network / ...) + ↓ +并行处理 + ├─ error 类型 + │ ├─ 写入 Loki(批量) + │ ├─ 更新错误聚合表(按指纹分组计数) + │ └─ 触发告警检测 + ├─ performance 类型 + │ ├─ 写入 Loki + │ └─ 更新性能指标表(P50/P95/P99) + └─ 其他类型 + └─ 写入 Loki +``` + +### 7.2 Loki 批量写入 + +使用 Loki 的 `/loki/api/v1/push` 接口,批量写入: + +```json +{ + "streams": [ + { + "stream": { + "project_id": "1001", + "level": "error", + "type": "error" + }, + "values": [ + ["", ""], + ["", ""] + ] + } + ] +} +``` + +### 7.3 MySQL 批量更新 + +- 使用 `INSERT ... ON DUPLICATE KEY UPDATE` +- 按批次聚合后一次性写入 +- 避免逐条更新,提升性能 + +--- + +## 八、错误码设计 + +| HTTP 状态码 | 说明 | 场景 | +|-------------|------|------| +| 200 | 成功 | 正常接收 | +| 204 | 成功无内容 | 同上,兼容不同 SDK | +| 400 | 请求格式错误 | envelope 格式不对、body 为空 | +| 401 | 认证失败 | publicKey 错误 | +| 404 | 项目不存在 | projectId 无效 | +| 413 | 请求体过大 | 超过 256KB | +| 429 | 限流 | 超过配额 | +| 500 | 服务器错误 | 内部异常 | + +--- + +## 九、可观测性 + +### 9.1 自身监控指标 + +| 指标 | 说明 | +|------|------| +| `events_received_total` | 接收事件总数 | +| `events_received_per_second` | 每秒接收数 | +| `events_dropped_total` | 丢弃事件总数 | +| `events_processed_total` | 处理成功总数 | +| `queue_size` | 当前队列长度 | +| `process_duration_ms` | 处理耗时(P50/P95/P99) | +| `loki_write_errors_total` | Loki 写入错误数 | +| `mysql_write_errors_total` | MySQL 写入错误数 | + +### 9.2 健康检查接口 + +``` +GET /health + +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00Z", + "uptime": 86400, + "queue_size": 123, + "events_processed": 1234567, + "loki": "connected", + "mysql": "connected" +} +``` + +--- + +## 十、横向扩展 + +### 10.1 无状态设计 + +- API Server 完全无状态 +- 项目配置缓存,启动时加载,定时刷新 +- 队列在内存中,扩展时直接加机器 + +### 10.2 负载均衡 + +- Nginx 层做负载均衡 +- 按 projectId 一致性哈希路由 +- 同一项目的事件打到同一台机器(有利于缓存和聚合) + +### 10.3 队列扩展 + +- 小流量:内存队列足够 +- 中流量:加 Redis 作为分布式队列 +- 大流量:加 Kafka(不推荐,太重了) diff --git a/docs/03-data-aggregation.md b/docs/03-data-aggregation.md new file mode 100644 index 0000000..60e0344 --- /dev/null +++ b/docs/03-data-aggregation.md @@ -0,0 +1,533 @@ +# 数据聚合与存储设计文档 + +## 一、设计原则 + +### 1.1 存储分层策略 + +``` +热数据(7天内) ←→ Loki + MySQL(高频查询) +温数据(7-30天) ←→ Loki(低频查询) +冷数据(30天+) ←→ 对象存储归档(极少查询) +``` + +### 1.2 存储选型 + +| 数据类型 | 存储 | 保留时长 | 说明 | +|----------|------|----------|------| +| 原始错误事件 | Loki | 7-30 天 | 堆栈、上下文、面包屑 | +| 原始性能事件 | Loki | 7 天 | 性能明细数据 | +| 原始行为事件 | Loki | 3 天 | PV、点击等(量大) | +| 错误聚合统计 | MySQL | 永久 | 按指纹 + 时间聚合 | +| 性能指标统计 | MySQL | 90 天 | 按指标 + 时间聚合 | +| 项目配置 | MySQL | 永久 | 项目、用户、告警规则 | +| 告警记录 | MySQL | 90 天 | 告警历史 | + +### 1.3 设计目标 + +- **Loki 存储成本**:100万错误事件 ≈ 5GB/月 +- **MySQL 存储成本**:聚合数据 ≈ 100MB/月/项目 +- **查询性能**:聚合查询 < 100ms,原始日志查询 < 2s + +--- + +## 二、Loki 存储设计 + +### 2.1 Label 设计 + +Loki 的 Label 是查询索引,设计原则:**低基数、高区分度**。 + +| Label | 说明 | 基数 | 示例 | +|-------|------|------|------| +| `project_id` | 项目 ID | 低(项目数) | 1001 | +| `type` | 事件类型 | 极低 | error / performance / network / behavior | +| `level` | 日志级别 | 极低 | fatal / error / warning / info | +| `platform` | 平台 | 低 | javascript / node / python | +| `environment` | 环境 | 低 | production / staging / development | +| `fingerprint` | 错误指纹 | 中 | abc123def | + +**反模式(不要用做 Label)**: +- ❌ `event_id`:基数太高,每个事件都不同 +- ❌ `message`:基数太高,且是文本 +- ❌ `url`:基数太高 +- ❌ `user_id`:基数太高 + +这些应该放在日志内容里,用 grep 查询。 + +### 2.2 日志格式(JSON) + +每条日志是一行 JSON,方便 Loki 的 `json` 解析器提取字段。 + +#### 错误事件格式 + +```json +{ + "event_id": "abc123...", + "type": "error", + "level": "error", + "timestamp": 1704067200000, + "project_id": "1001", + "platform": "javascript", + "environment": "production", + "release": "1.0.0", + "fingerprint": "a1b2c3d4e5f6", + + "message": "Cannot read property 'foo' of undefined", + "exception": { + "type": "TypeError", + "value": "Cannot read property 'foo' of undefined", + "stacktrace": { + "frames": [ + { + "filename": "https://example.com/app.js", + "function": "onClick", + "lineno": 123, + "colno": 45, + "in_app": true + } + ] + } + }, + + "user": { + "id": "123", + "username": "testuser" + }, + + "tags": { + "page": "/home", + "browser": "Chrome 120" + }, + + "extra": {}, + "breadcrumbs": [], + + "request": { + "url": "https://example.com/page", + "headers": { + "user_agent": "Mozilla/5.0..." + } + } +} +``` + +#### 性能事件格式 + +```json +{ + "type": "performance", + "level": "info", + "timestamp": 1704067200000, + "project_id": "1001", + + "metric": "lcp", + "value": 2500, + "unit": "ms", + "rating": "good", + + "tags": { + "page_url": "https://example.com/page", + "route": "/home", + "browser": "Chrome 120", + "os": "Mac OS X" + } +} +``` + +#### 网络事件格式 + +```json +{ + "type": "network", + "level": "info", + "timestamp": 1704067200000, + "project_id": "1001", + + "sub_type": "fetch", + "method": "GET", + "url": "/api/users", + "status_code": 200, + "duration": 123, + "success": true, + + "tags": { + "route": "/home" + } +} +``` + +### 2.3 Loki 查询示例 + +**查询某项目的错误总数(1小时内)** +```logql +count_over_time( + {project_id="1001", type="error"}[1h] +) +``` + +**查询某错误指纹的出现次数** +```logql +count_over_time( + {project_id="1001", fingerprint="a1b2c3d4"}[24h] +) +``` + +**查询某页面的 LCP P95** +```logql +quantile_over_time( + 0.95, + {project_id="1001", type="performance"} + | json value=value + | metric="lcp" + | unwrap value + [5m] +) +``` + +--- + +## 三、MySQL 聚合表设计 + +### 3.1 错误聚合表 + +#### `error_stats_hourly` - 错误小时统计表 + +按错误指纹 + 项目 + 小时聚合,用于趋势图。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 主键 | +| `project_id` | VARCHAR(64) | 项目 ID | +| `fingerprint` | VARCHAR(64) | 错误指纹 | +| `stat_hour` | DATETIME | 统计小时(整点) | +| `error_type` | VARCHAR(128) | 错误类型(TypeError 等) | +| `error_message` | VARCHAR(512) | 错误消息(截断) | +| `count` | INT | 发生次数 | +| `affected_users` | INT | 影响用户数(估算) | +| `first_seen` | DATETIME | 首次出现时间 | +| `last_seen` | DATETIME | 最后出现时间 | +| `created_at` | DATETIME | 创建时间 | +| `updated_at` | DATETIME | 更新时间 | + +**索引**: +- `(project_id, stat_hour)` - 按项目+时间查询 +- `(project_id, fingerprint, stat_hour)` - 按指纹+时间查询 +- 唯一键:`(project_id, fingerprint, stat_hour)` + +#### `error_issues` - 错误 Issue 表 + +按错误指纹聚合,用于错误列表管理。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 主键 | +| `project_id` | VARCHAR(64) | 项目 ID | +| `fingerprint` | VARCHAR(64) UNIQUE | 错误指纹 | +| `error_type` | VARCHAR(128) | 错误类型 | +| `error_message` | TEXT | 错误消息 | +| `stack_trace` | TEXT | 堆栈摘要(前几帧) | +| `level` | VARCHAR(32) | 级别 | +| `platform` | VARCHAR(32) | 平台 | +| `total_count` | BIGINT | 总次数 | +| `today_count` | INT | 今日次数 | +| `yesterday_count` | INT | 昨日次数 | +| `affected_users` | INT | 影响用户数 | +| `status` | VARCHAR(32) | 状态:active / resolved / ignored | +| `assignee` | VARCHAR(64) | 处理人 | +| `first_seen` | DATETIME | 首次出现 | +| `last_seen` | DATETIME | 最后出现 | +| `created_at` | DATETIME | 创建时间 | +| `updated_at` | DATETIME | 更新时间 | + +**状态流转**: +``` +active ──→ resolved ──→ active(再次出现时复活) + │ + └──→ ignored +``` + +### 3.2 性能统计表 + +#### `performance_stats_hourly` - 性能小时统计表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 主键 | +| `project_id` | VARCHAR(64) | 项目 ID | +| `metric` | VARCHAR(32) | 指标名:lcp / fcp / cls / fid | +| `stat_hour` | DATETIME | 统计小时 | +| `page_url` | VARCHAR(256) | 页面 URL(可选,空=全部) | +| `sample_count` | INT | 样本数 | +| `p50` | DOUBLE | 中位数 | +| `p75` | DOUBLE | 75 分位 | +| `p90` | DOUBLE | 90 分位 | +| `p95` | DOUBLE | 95 分位 | +| `p99` | DOUBLE | 99 分位 | +| `avg` | DOUBLE | 平均值 | +| `good_rate` | DOUBLE | Good 比例(0-1) | +| `poor_rate` | DOUBLE | Poor 比例(0-1) | +| `created_at` | DATETIME | 创建时间 | +| `updated_at` | DATETIME | 更新时间 | + +**索引**: +- `(project_id, metric, stat_hour)` - 主查询索引 +- 唯一键:`(project_id, metric, stat_hour, page_url)` + +#### `performance_stats_daily` - 性能日统计表 + +同上,按天聚合,用于长期趋势。 + +### 3.3 网络请求统计表 + +#### `network_stats_hourly` - 网络小时统计表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 主键 | +| `project_id` | VARCHAR(64) | 项目 ID | +| `stat_hour` | DATETIME | 统计小时 | +| `method` | VARCHAR(16) | 请求方法 | +| `url_pattern` | VARCHAR(512) | URL 模式(归一化后) | +| `total_count` | INT | 总请求数 | +| `error_count` | INT | 错误数(4xx + 5xx) | +| `error_rate` | DOUBLE | 错误率 | +| `avg_duration` | DOUBLE | 平均耗时(ms) | +| `p50_duration` | DOUBLE | P50 耗时 | +| `p95_duration` | DOUBLE | P95 耗时 | +| `created_at` | DATETIME | 创建时间 | + +**URL 归一化**: +- `/api/users/123` → `/api/users/:id` +- `/static/app.abc123.js` → `/static/app.[hash].js` + +### 3.4 环境维度表 + +用于减少 Loki 中环境信息的冗余,配合 SDK 的字典编码和会话级共享使用。 + +#### `env_dimensions` - 环境维度表 + +每条唯一的环境组合只存 1 条,日志中通过 `env_id` 关联。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 维度 ID | +| `project_id` | VARCHAR(64) | 项目 ID | +| `browser_name` | VARCHAR(32) | 浏览器名称 | +| `browser_version` | VARCHAR(32) | 浏览器主版本 | +| `os_name` | VARCHAR(32) | 操作系统 | +| `os_version` | VARCHAR(32) | OS 主版本 | +| `device_family` | VARCHAR(64) | 设备系列 | +| `device_model` | VARCHAR(64) | 设备型号 | +| `screen_resolution` | VARCHAR(32) | 屏幕分辨率 | +| `language` | VARCHAR(16) | 浏览器语言 | +| `hash` | VARCHAR(64) UNIQUE | 所有维度的哈希值,用于快速查找 | +| `first_seen` | DATETIME | 首次出现 | +| `last_seen` | DATETIME | 最后出现 | +| `count` | BIGINT | 出现次数(用于热度排序) | + +**唯一键**:`(project_id, hash)` + +**为什么用维度表?** +- Loki 的 JSON 日志里只存 `env_id`(1 个数字),不存完整的浏览器/OS/设备信息 +- 每条日志节省 100-200 字节,百万级事件节省几十 GB +- 查询时 JOIN 维度表,或者直接用 Grafana 的变量查询 + +**服务端处理流程**: +``` +事件到达 → 计算环境维度的 hash → + ├─ 已存在 → 取 env_id,更新 last_seen + count + └─ 不存在 → 插入新记录,返回新 env_id +→ 日志写入 Loki,只带 env_id +``` + +#### `page_dimensions` - 页面维度表 + +同理,页面 URL 也可以做维度化: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 页面 ID | +| `project_id` | VARCHAR(64) | 项目 ID | +| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 | +| `path` | VARCHAR(512) | 路由路径 | +| `title` | VARCHAR(256) | 页面标题 | +| `hash` | VARCHAR(64) UNIQUE | 哈希 | +| `count` | BIGINT | 访问次数 | + +#### `api_dimensions` - 接口维度表 + +网络请求的 URL、method 等也可以维度化,日志里只存 `api_id`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT PK | 接口 ID | +| `project_id` | VARCHAR(64) | 项目 ID | +| `method` | VARCHAR(16) | 请求方法(GET/POST/...) | +| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 | +| `domain` | VARCHAR(128) | 域名 | +| `hash` | VARCHAR(64) UNIQUE | method + url_pattern 的哈希 | +| `total_count` | BIGINT | 总请求数 | +| `error_count` | BIGINT | 错误数 | +| `avg_duration` | DOUBLE | 平均耗时 | +| `last_seen` | DATETIME | 最后出现时间 | + +**为什么要做接口维度化?** +- 日志里只存 `api_id`(8 字节),不用存完整 URL +- URL 归一化后,同一路由的不同参数只会产生 1 条维度记录 +- 按接口统计错误率、耗时等指标时,直接 JOIN 维度表即可 + +### 3.5 项目配置表 + +#### `projects` - 项目表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | VARCHAR(64) PK | 内部 ID(proj_xxx) | +| `project_id` | VARCHAR(32) UNIQUE | Sentry 项目 ID(纯数字) | +| `public_key` | VARCHAR(64) | 公钥(默认=内部 ID) | +| `name` | VARCHAR(128) | 项目名称 | +| `platform` | VARCHAR(32) | 平台 | +| `environment` | VARCHAR(32) | 默认环境 | +| `description` | TEXT | 描述 | +| `status` | VARCHAR(32) | 状态:active / disabled | +| `rate_limit` | INT | 速率限制(事件/天) | +| `sample_rate` | DOUBLE | 采样率(0-1) | +| `created_at` | DATETIME | 创建时间 | +| `updated_at` | DATETIME | 更新时间 | + +#### `alert_rules` - 告警规则表 + +见告警引擎设计文档。 + +--- + +## 四、聚合任务设计 + +### 4.1 聚合方式 + +| 方式 | 实时性 | 复杂度 | 适用场景 | +|------|--------|--------|----------| +| **实时增量聚合** | 秒级 | 中 | 错误计数、Issue 更新 | +| **定时批量聚合** | 分钟级 | 低 | 性能分位数、小时统计 | +| **离线重算** | 天级 | 高 | 数据修正、历史回刷 | + +### 4.2 实时增量聚合(错误计数) + +每次事件处理时,直接更新 MySQL: + +```sql +INSERT INTO error_issues + (project_id, fingerprint, error_type, error_message, + total_count, today_count, last_seen, first_seen, status) +VALUES + (?, ?, ?, ?, 1, 1, NOW(), NOW(), 'active') +ON DUPLICATE KEY UPDATE + total_count = total_count + 1, + today_count = today_count + 1, + last_seen = NOW(), + status = CASE WHEN status = 'resolved' THEN 'active' ELSE status END; +``` + +**优点**:实时性好,数据立刻可见 +**缺点**:高并发下 MySQL 压力大 + +**优化**: +- 内存中先做 1 秒级别的微批聚合,再批量写 MySQL +- 使用 Redis 做计数缓冲,定期刷入 MySQL + +### 4.3 定时批量聚合(性能统计) + +使用 Cron 定时任务,每 5 分钟从 Loki 拉取数据聚合: + +``` +每 5 分钟执行: + 1. 从 Loki 查询过去 5 分钟的性能事件 + 2. 按 project + metric + 页面 分组 + 3. 计算 P50/P90/P95/avg 等指标 + 4. 写入 performance_stats_hourly 表 +``` + +**为什么用定时任务而不是实时?** +- 性能指标不需要秒级实时 +- 分位数计算需要一定数据量才准确 +- 定时批量更省资源 + +### 4.4 数据过期与归档 + +#### Loki 数据过期 + +通过 Loki 的 `retention` 配置自动删除: + +```yaml +limits_config: + retention_period: 168h # 7 天 +``` + +#### MySQL 数据过期 + +- 小时统计表:保留 30 天 +- 日统计表:保留 1 年 +- 错误 Issue 表:永久保留(只存聚合数据,量很小) + +定时任务每天凌晨清理过期数据。 + +--- + +## 五、数据迁移与兼容 + +### 5.1 当前状态 + +目前项目使用 JSON 文件存储项目配置,Loki 存储原始日志,没有 MySQL 聚合层。 + +### 5.2 演进路径 + +**阶段 1:JSON → SQLite(单机版)** +- 零依赖,开箱即用 +- 适合个人项目、小团队 +- 单节点足够 + +**阶段 2:SQLite → MySQL(生产版)** +- 支持并发 +- 性能更好 +- 适合多项目、中大型团队 + +**阶段 3:增加 ClickHouse(大规模)** +- 亿级数据量 +- 复杂分析查询 +- 一般不需要 + +--- + +## 六、Grafana 数据源配置 + +### 6.1 Loki 数据源 + +- URL: `http://loki:3100` +- 开启 JSON 解析 +- 配置 Derived fields(从日志跳转到追踪) + +### 6.2 MySQL 数据源 + +- 用于展示聚合数据(性能趋势、错误趋势) +- 比 Loki 查询更稳定、更快 + +--- + +## 七、数据安全 + +### 7.1 数据脱敏 + +- 入库前脱敏:SDK 端 + 服务端双重脱敏 +- 查询时脱敏:敏感字段查询结果自动打码 +- 导出时脱敏:导出数据默认脱敏 + +### 7.2 数据隔离 + +- 项目间完全隔离(通过 project_id label) +- 查询时强制带 project_id 条件 +- 管理后台有项目权限控制 + +### 7.3 数据备份 + +- MySQL:每日全量备份 + binlog 增量备份 +- Loki:定期快照到对象存储 +- 配置文件:Git 版本管理 diff --git a/docs/04-alert-engine.md b/docs/04-alert-engine.md new file mode 100644 index 0000000..e2e64c6 --- /dev/null +++ b/docs/04-alert-engine.md @@ -0,0 +1,451 @@ +# 告警引擎设计文档 + +## 一、设计原则 + +### 1.1 核心原则 + +- **快**:错误发生后 1 分钟内触发告警 +- **准**:减少误报,告警要有价值 +- **全**:支持多种告警规则和通知渠道 +- **轻**:不依赖复杂组件,轻量实现 + +### 1.2 告警目标 + +- 错误突增:及时发现线上故障 +- 新错误出现:第一时间感知新问题 +- 错误率超标:质量红线监控 +- 性能劣化:用户体验下降预警 + +--- + +## 二、告警规则类型 + +### 2.1 规则分类 + +| 类型 | 说明 | 实时性 | 复杂度 | +|------|------|--------|--------| +| **阈值告警** | 指标超过固定阈值 | 高 | 低 | +| **突增告警** | 同比/环比增长超过阈值 | 中 | 中 | +| **新错误告警** | 出现新的错误指纹 | 高 | 低 | +| **错误率告警** | 错误率超过阈值 | 中 | 中 | +| **质量分告警** | 性能/质量综合评分下降 | 低 | 高 | + +### 2.2 内置规则模板 + +#### 1. 错误数量突增 + +```yaml +name: 错误数量突增 +type: spike +metric: error_count +window: 5m # 检测窗口 +compare: 1h_ago # 对比:1小时前 +threshold: 200% # 增长 200% 触发 +min_count: 10 # 最少 10 条才检测(避免噪音) +level: warning +``` + +#### 2. 新错误出现 + +```yaml +name: 新错误出现 +type: new_error +metric: error_fingerprint +window: 24h # 过去 24 小时没出现过 +level: info +``` + +#### 3. JS 错误率过高 + +```yaml +name: JS 错误率过高 +type: threshold +metric: error_rate # 错误数 / PV +window: 5m +threshold: 5% # 错误率超过 5% +level: critical +``` + +#### 4. 页面性能劣化 + +```yaml +name: LCP 性能劣化 +type: threshold +metric: lcp_p95 +window: 15m +threshold: 4000 # P95 LCP > 4s +level: warning +``` + +#### 5. 接口错误率过高 + +```yaml +name: 接口错误率过高 +type: threshold +metric: api_error_rate +window: 5m +threshold: 10% +level: critical +``` + +--- + +## 三、告警引擎架构 + +### 3.1 整体架构 + +``` +┌──────────────────────────────────────────────────────┐ +│ 事件流 │ +│ 错误事件 ──→ 实时检测 ──→ 触发规则 ──→ 告警通知 │ +│ (快路径) │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ 定时任务 │ +│ Loki/MySQL ──→ 指标计算 ──→ 规则匹配 ──→ 告警通知 │ +│ (慢路径) │ +└──────────────────────────────────────────────────────┘ +``` + +### 3.2 双路径设计 + +#### 快路径:实时检测(秒级) + +用于:新错误出现、错误数突增(粗粒度) + +``` +事件入队 + ↓ +实时规则引擎 + ├─ 检查是否是新指纹(内存 BloomFilter) + ├─ 检查 1 分钟错误数是否突增 + └─ 触发 → 入告警队列 + ↓ +告警收敛 + 去重 + ↓ +通知 +``` + +#### 慢路径:定时检测(分钟级) + +用于:复杂计算、分位数、错误率 + +``` +定时任务(每 5 分钟) + ↓ +从 Loki/MySQL 拉取指标 + ↓ +计算同比、环比、分位数 + ↓ +匹配告警规则 + ↓ +触发 → 入告警队列 + ↓ +告警收敛 + 去重 + ↓ +通知 +``` + +--- + +## 四、告警规则引擎 + +### 4.1 规则数据结构 + +```sql +CREATE TABLE alert_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + project_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + description TEXT, + + -- 规则类型 + type VARCHAR(32) NOT NULL, -- threshold / spike / new_error / error_rate + + -- 规则配置(JSON) + config JSON NOT NULL, + /* + { + "metric": "error_count", + "window": "5m", + "threshold": 100, + "compare": "1h_ago", + "operator": ">" + } + */ + + -- 告警级别 + level VARCHAR(32) NOT NULL, -- info / warning / critical + + -- 通知渠道 + channels JSON NOT NULL, + /* + [ + { "type": "webhook", "url": "https://..." }, + { "type": "email", "to": ["a@b.com"] } + ] + */ + + -- 收敛配置 + group_by VARCHAR(64), -- 按指纹/项目/页面分组 + interval INT DEFAULT 300, -- 同组告警间隔(秒) + max_count INT DEFAULT 10, -- 每小时最多告警次数 + + -- 状态 + enabled TINYINT DEFAULT 1, + created_at DATETIME, + updated_at DATETIME, + + INDEX idx_project (project_id) +); +``` + +### 4.2 规则匹配流程 + +``` +新指标数据到达 + ↓ +加载项目的所有启用规则 + ↓ +逐条匹配 + ├─ 类型匹配? + ├─ 条件满足? + └─ 未被静默? + ↓ +触发告警 + ↓ +告警去重 + 收敛 + ↓ +发送通知 +``` + +### 4.3 内置规则表达式 + +支持简单的表达式语法: + +``` +# 阈值比较 +error_count > 100 +error_rate > 0.05 +lcp_p95 >= 4000 + +# 同比环比 +error_count / error_count_1h_ago > 2 +error_count > error_count_1d_ago * 1.5 +``` + +--- + +## 五、告警收敛与降噪 + +### 5.1 降噪策略 + +| 策略 | 说明 | 效果 | +|------|------|------| +| **同指纹去重** | 同一错误 5 分钟内只告警 1 次 | 减少 80% 重复告警 | +| **分组收敛** | 按项目/级别聚合,合并发送 | 减少通知数量 | +| **频率限制** | 每项目每小时最多 N 条 | 防止告警风暴 | +| **静默期** | 已知问题可设置静默 | 忽略已知问题 | +| **最小阈值** | 数量太少不告警 | 避免噪音 | + +### 5.2 告警去重键 + +``` +去重键 = project_id + rule_id + fingerprint + 时间窗口 +``` + +同一去重键在 `interval` 时间内只发 1 次告警。 + +### 5.3 告警升级 + +- 持续时间超过 30 分钟 → 级别升级(warning → critical) +- 影响用户数超过阈值 → 级别升级 +- 持续超过 2 小时 → 通知更多人 + +--- + +## 六、通知渠道 + +### 6.1 支持的渠道 + +| 渠道 | 说明 | 适用场景 | +|------|------|----------| +| **Webhook** | 通用 HTTP 回调 | 飞书、钉钉、企业微信、Slack | +| **邮件** | SMTP 发送 | 正式通知、归档 | +| **Server酱** | 微信推送 | 个人项目 | +| **飞书机器人** | 专用适配 | 飞书团队 | +| **钉钉机器人** | 专用适配 | 钉钉团队 | + +### 6.2 Webhook 格式 + +```json +{ + "alert_id": "alert_abc123", + "rule_id": 123, + "rule_name": "错误数量突增", + "level": "critical", + "project_id": "1001", + "project_name": "前端项目", + + "metric": "error_count", + "value": 250, + "threshold": 100, + "window": "5m", + + "description": "5 分钟内错误数达到 250,超过阈值 100,增长 150%", + + "details": { + "error_type": "TypeError", + "top_errors": [ + { "fingerprint": "a1b2c3", "message": "...", "count": 100 }, + { "fingerprint": "d4e5f6", "message": "...", "count": 50 } + ] + }, + + "link": "https://log.example.com/manage/#/errors?project=1001", + "timestamp": "2024-01-01T00:00:00Z" +} +``` + +### 6.3 飞书 / 钉钉适配 + +提供现成的模板,直接粘贴 Webhook URL 即可使用: + +- 飞书:使用富文本卡片,支持点击跳转 +- 钉钉:使用 Markdown 格式,支持 @人 + +--- + +## 七、告警事件存储 + +### 7.1 告警历史表 + +```sql +CREATE TABLE alert_events ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + project_id VARCHAR(64) NOT NULL, + rule_id BIGINT NOT NULL, + rule_name VARCHAR(128), + level VARCHAR(32), + + -- 告警数据 + metric VARCHAR(64), + value DOUBLE, + threshold DOUBLE, + description TEXT, + details JSON, + + -- 状态 + status VARCHAR(32) DEFAULT 'firing', -- firing / resolved / acknowledged + + -- 时间 + started_at DATETIME, + resolved_at DATETIME NULL, + duration INT, -- 持续时间(秒) + + created_at DATETIME, + updated_at DATETIME, + + INDEX idx_project_time (project_id, started_at), + INDEX idx_status (status) +); +``` + +### 7.2 状态流转 + +``` +firing ──→ acknowledged ──→ resolved + │ ↑ + └───────────────────────────┘ + 自动恢复(指标降下来) +``` + +--- + +## 八、实现方案 + +### 8.1 轻量实现(v1) + +**技术栈**:Node.js + 内存定时器 + MySQL + +``` +API Server 进程内 + ├─ 事件消费时同步检测(快路径) + │ ├─ 新错误检测(BloomFilter) + │ └─ 简单计数(1min 滑动窗口) + │ + └─ 定时任务(慢路径) + └─ 每 5 分钟跑一次复杂规则 +``` + +**优点**:无额外组件,部署简单 +**缺点**:单进程,无法水平扩展 + +### 8.2 独立服务(v2) + +**技术栈**:独立的 Alert Worker 进程 + Redis + +``` +API Server → Redis Stream → Alert Worker + → 规则匹配 + → 告警收敛 + → 通知发送 +``` + +**优点**:可独立扩展,不影响接入性能 +**缺点**:多一个组件 + +--- + +## 九、静默与抑制 + +### 9.1 静默规则 + +用户可以设置静默期,暂时忽略某些告警: + +```yaml +- project_id: 1001 + fingerprint: a1b2c3d4 + reason: "已知问题,下个版本修复" + start_time: 2024-01-01 00:00:00 + end_time: 2024-01-03 00:00:00 + created_by: user1 +``` + +### 9.2 抑制规则 + +高优先级告警抑制低优先级: + +- critical 级别的错误告警触发后,同指纹的 warning 级告警被抑制 +- 项目级别的大故障告警触发后,该项目的其他告警被抑制 + +--- + +## 十、与现有系统集成 + +### 10.1 当前状态 + +- 有 Loki 存原始数据 → 可用于慢路径查询 +- 有 MySQL(待加聚合层)→ 可存规则和告警历史 +- 有管理后台 → 可加告警管理页面 + +### 10.2 落地步骤 + +**v1(最小可用)**: +1. 新增 alert_rules 和 alert_events 表 +2. 实现新错误检测(快路径,内存 BloomFilter) +3. 实现 Webhook 通知 +4. 管理后台加告警规则配置页面 + +**v2(增强)**: +1. 实现定时规则引擎(慢路径) +2. 增加突增检测、错误率检测 +3. 支持飞书/钉钉专用模板 +4. 告警收敛和静默功能 + +**v3(完善)**: +1. 独立 Alert Worker 服务 +2. Redis 队列 + 分布式锁 +3. 更复杂的规则表达式 +4. 告警抑制和升级 diff --git a/docs/05-admin-dashboard.md b/docs/05-admin-dashboard.md new file mode 100644 index 0000000..8c168c5 --- /dev/null +++ b/docs/05-admin-dashboard.md @@ -0,0 +1,916 @@ +# 管理后台与可视化设计文档 + +## 一、设计原则 + +### 1.1 核心理念 + +**克制、高效、数据为先** + +- 不做花哨的动效,所有设计都服务于「快速定位问题」 +- 深色主题为主,长时间盯着不累眼 +- 信息分层明确,重要的信息一眼看到,次要的信息收起来 +- 操作路径短:3 次点击以内能到达任何核心页面 + +### 1.2 技术选型 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 框架 | React 18 | 生态成熟,社区活跃 | +| 构建 | Vite 5 | 快,开发体验好 | +| 语言 | TypeScript | 类型安全,减少 bug | +| UI 库 | Ant Design 5 | 后台管理组件最全,深色主题好 | +| 路由 | React Router v6 | 官方推荐,功能完整 | +| 状态管理 | Zustand | 轻量,比 Redux 简单太多 | +| 数据请求 | TanStack Query (React Query) | 缓存、重发、分页都有了 | +| 图表 | ECharts 5 | 功能强大,国内用得多 | +| HTTP 客户端 | Axios | 拦截器、取消请求都方便 | +| 样式 | CSS Modules + Less | 按需定制主题 | +| 代码规范 | ESLint + Prettier | 统一代码风格 | + +### 1.3 为什么选 Ant Design + +- **组件最丰富**:表格、表单、弹窗、树形...后台需要的都有 +- **深色主题好**:AntD 5 原生支持 ConfigProvider 切换主题 +- **ProComponents**:ProTable、ProForm 等高级组件,开发效率翻倍 +- **生态成熟**:遇到问题搜一下就有答案 +- **设计语言统一**:有自己的设计规范,不用从零定 + +### 1.3 页面清单 + +| 页面 | 路径 | 优先级 | 说明 | +|------|------|--------|------| +| 项目列表 | `/manage/` | P0 | 项目管理(已有) | +| 项目概览 | `#/overview` | P0 | 项目总览、关键指标 | +| 错误列表 | `#/errors` | P0 | 错误 Issue 列表、详情 | +| 错误详情 | `#/errors/:id` | P0 | 单个错误的详细分析 | +| 性能分析 | `#/performance` | P1 | Web Vitals、性能趋势 | +| 网络分析 | `#/network` | P2 | 接口请求统计 | +| 行为分析 | `#/behavior` | P2 | PV、用户行为 | +| 告警中心 | `#/alerts` | P1 | 告警规则、告警历史 | +| 日志查询 | `#/logs` | P1 | Loki 原始日志查询 | +| 接入指南 | `#/integration` | P0 | SDK 接入文档(已有) | +| 项目设置 | `#/settings` | P1 | 项目配置、成员、采样率 | + +--- + +## 二、设计系统 + +### 2.1 配色系统 + +**主色调**:蓝色系(专业、可信赖) + +| 用途 | 颜色 | 色值 | +|------|------|------| +| 品牌主色 | 亮蓝 | `#0090f9` | +| 品牌主色(悬停) | 蓝 | `#1da1f2` | +| 成功 | 绿 | `#26a641` | +| 警告 | 黄 | `#e9c46a` | +| 错误 | 红 | `#e63946` | +| 信息 | 紫 | `#a855f7` | + +**中性色(深色主题)**: + +| 用途 | 色值 | 说明 | +|------|------|------| +| 背景(最深) | `#0d1117` | 页面底色 | +| 背景(卡片) | `#161b22` | 卡片、弹窗 | +| 背景(悬浮) | `#21262d` | hover、选中 | +| 边框 | `#30363d` | 分割线、边框 | +| 文字(主) | `#e6edf3` | 标题、正文 | +| 文字(次) | `#8b949e` | 辅助说明、时间 | +| 文字(弱) | `#6e7681` | 占位符、disabled | + +### 2.2 字体与间距 + +**字体栈**: +```css +font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +``` + +**字号阶梯**: + +| 用途 | 大小 | 字重 | +|------|------|------| +| 页面大标题 | 24px | 600 | +| 区块标题 | 18px | 600 | +| 卡片标题 | 16px | 600 | +| 正文 | 14px | 400 | +| 辅助文字 | 12px | 400 | + +**间距阶梯**(4px 基准): +`4px / 8px / 12px / 16px / 20px / 24px / 32px / 48px` + +### 2.3 圆角与阴影 + +| 元素 | 圆角 | 阴影 | +|------|------|------| +| 按钮 | 6px | 无 | +| 输入框 | 6px | 无 | +| 卡片 | 8px | 无(用边框区分) | +| 弹窗 | 12px | `0 8px 24px rgba(0,0,0,0.4)` | +| 下拉菜单 | 8px | `0 4px 12px rgba(0,0,0,0.3)` | + +--- + +## 三、整体布局 + +### 3.1 布局结构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Top Bar(64px) │ +│ ┌──────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Logo │ │ 项目选择器 ▼ │ │ 🔔 👤 设置 │ │ +│ └──────┘ └──────────────┘ └─────────────┘ │ +├────────┬────────────────────────────────────────────────────────┤ +│ │ │ +│ Side │ Content Area │ +│ Nav │ ┌──────────────────────────────────────────────────┐ │ +│(64px │ │ Page Header(标题 + 操作 + 筛选) │ │ +│ 展开 │ └──────────────────────────────────────────────────┘ │ +│ 220px)│ ┌──────────────────────────────────────────────────┐ │ +│ │ │ Metric Cards(指标卡片行) │ │ +│ │ └──────────────────────────────────────────────────┘ │ +│ │ ┌──────────────────────────────────────────────────┐ │ +│ │ │ Charts / Tables(主内容区) │ │ +│ │ │ │ │ +│ │ │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ +│ │ │ +└────────┴────────────────────────────────────────────────────────┘ +``` + +### 3.2 侧边栏(可折叠) + +**收起态(64px)**:只显示图标 +``` +📊 ← 概览 +🐛 ← 错误 +⚡ ← 性能 +🌐 ← 网络 +📈 ← 看板 +🔔 ← 告警 +⚙️ ← 设置 +``` + +**展开态(220px)**:图标 + 文字 + 角标 +``` +📊 概览 +🐛 错误 12 ← 未解决错误数 +⚡ 性能 3 ← 有性能告警 +🌐 网络 +📈 看板 +🔔 告警 5 ← 未读告警 +⚙️ 设置 +``` + +### 3.3 顶部栏 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ◀▶ 🪲 Light-Sentry [▼ 前端项目 - my-app ] 🔔 ⚙️ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 左侧:折叠按钮 + Logo + 产品名 +- 中间:项目选择器(下拉,可搜索) +- 右侧:告警铃(有未读红点) + 设置 + +### 3.4 页面头(每个页面都有) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 错误 Issues [时间范围: 24h ▼] [搜索] │ +│ 12 个未解决 · 3 个今日新增 [导出] [刷新] │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、核心页面详细设计 + +### 4.1 概览页(Overview) + +**定位**:一屏看完项目健康状况,有问题快速跳转到对应页面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 概览 [24h ▼] [环境: 全部 ▼] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 错误数 │ │ 错误率 │ │ 影响用户│ │ LCP P95 │ │ CLS P95 │ │ +│ │ 128 │ │ 2.3% │ │ 1,234 │ │ 3.2s │ │ 0.15 │ │ +│ │ ↑15% │ │ ↑0.8% │ │ ↑12% │ │ 🔴 差 │ │ 🟡 中 │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │ +│ │ 错误趋势(24h) │ │ Top 5 错误 │ │ +│ │ ╱╲ │ │ 1. TypeError: Cannot read... │ │ +│ │ ╱ ╲ ╱╲ │ │ 2. AxiosError: 500 Inter... │ │ +│ │ ╱╲╱ ╲╱ ╲ │ │ 3. ReferenceError: x is ... │ │ +│ │ │ │ 4. ChunkLoadError: Loadin... │ │ +│ │ │ │ 5. TypeError: Cannot set... │ │ +│ └──────────────────────────┘ └───────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │ +│ │ 性能趋势(LCP/FID/CLS) │ │ 新错误(今日新增) │ │ +│ │ 🟢 LCP 2.5s │ │ • TypeError: Cannot read... │ │ +│ │ 🟡 FID 120ms │ │ • AxiosError: Network Er... │ │ +│ │ 🟢 CLS 0.08 │ │ • ReferenceError: foo i... │ │ +│ │ │ │ │ │ +│ └──────────────────────────┘ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 实时错误流(最近 5 分钟) │ │ +│ │ 14:32:15 TypeError: Cannot read property 'foo' │ │ +│ │ 14:32:10 AxiosError: Request failed with 500 │ │ +│ │ 14:32:05 ReferenceError: x is not defined │ │ +│ │ ... │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**设计要点**: +- 5 个核心指标卡片放在最上面,一眼看完 +- 指标卡片底部的小数字是「同比昨日」,红涨绿跌 +- 左图右表,趋势 + Top 排行 +- 底部实时流,让你感知系统状态 + +### 4.2 错误列表页(Errors) + +**定位**:浏览和管理错误 Issue,找到要处理的问题 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 错误 Issues [24h ▼] [🔍 搜索错误消息] │ +│ 128 个错误 · 12 个未解决 · 3 个今日新增 [状态: 全部 ▼] [级别: ▼] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───┬─────────────────────┬──────┬───────┬───────┬──────────┐ │ +│ │ ✓ │ 错误消息 │ 级别 │ 次数 │ 用户 │ 最后出现 │ │ +│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │ +│ │ 🔴│Cannot read property│error │ 456 │ 123 │ 2 分钟前 │ │ +│ │ │ 'foo' of undefined│ │ │ │ │ │ +│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │ +│ │ 🔴│AxiosError: Request │error │ 234 │ 89 │ 5 分钟前 │ │ +│ │ │ failed with 500 │ │ │ │ │ │ +│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │ +│ │ 🟡│ReferenceError: x is│warning│ 123 │ 45 │ 12 分钟前 │ │ +│ │ │ not defined │ │ │ │ │ │ +│ └───┴─────────────────────┴──────┴───────┴───────┴──────────┘ │ +│ │ +│ ◀ 1 2 3 4 ... 12 ▶ 每页 20 条 ▼ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**筛选器**: +- 时间范围(15m / 1h / 6h / 24h / 7d / 30d / 自定义) +- 状态(全部 / 未解决 / 已解决 / 已忽略) +- 级别(全部 / fatal / error / warning / info) +- 搜索(错误消息、指纹、文件路径) +- 环境 +- 版本(release) + +**列表交互**: +- 点击行 → 进入错误详情 +- 行前 checkbox → 批量操作(标记已解决、忽略) +- hover 行 → 显示快捷操作(查看、已解决、忽略) + +### 4.3 错误详情页 + +**定位**:深入分析单个错误,定位根因 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ← 返回列表 [标记已解决] [忽略] [分配给 ▼] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 🔴 TypeError: Cannot read property 'foo' of undefined │ +│ Uncaught exception · active · 来自浏览器 JS │ +│ │ +│ 📊 概览 📝 堆栈 📋 Breadcrumbs 👥 用户 🌐 分布 ⏱️ 趋势 │ +│ ───── │ +│ │ +│ ┌──────────────┬──────────────┬──────────────┬──────────────┐ │ +│ │ 总次数 │ 影响用户 │ 首次出现 │ 最后出现 │ │ +│ │ 456 │ 123 │ 3 天前 │ 2 分钟前 │ │ +│ └──────────────┴──────────────┴──────────────┴──────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 发生趋势(7 天) │ │ +│ │ ╱╲ │ │ +│ │ ╱ ╲ ╱╲ │ │ +│ │ ╱╲╱ ╲╱ ╲ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 堆栈跟踪 │ │ +│ │ 📄 app.js:123 onClick │ │ +│ │ 📄 utils.js:45 handleClick │ │ +│ │ 📄 index.js:8 main │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 最近出现(最新 5 条) │ │ +│ │ 14:32:15 user_123 Chrome 120 macOS 点击按钮时 │ │ +│ │ 14:31:45 user_456 Safari 17 iOS 加载页面时 │ │ +│ │ ... │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Tab 内容**: +- **概览**:核心数据 + 趋势图 + 最近出现 +- **堆栈**:完整堆栈跟踪,可折叠,点击跳源码(如果配置了 sourcemap) +- **Breadcrumbs**:用户操作路径,时间线展示 +- **用户**:受影响的用户列表 +- **分布**:浏览器、OS、设备、地区分布 +- **趋势**:更长时间的趋势图 + +### 4.4 性能分析页 + +**定位**:看页面性能好不好,哪里慢 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 性能分析 [24h ▼] [页面: 全部 ▼] │ +│ Web Vitals 指标 [浏览器: 全部 ▼] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ LCP │ │ FID │ │ CLS │ │ FCP │ │ +│ │ 3.2s 🔴 │ │ 120ms 🟡 │ │ 0.15 🟢 │ │ 1.8s 🟡 │ │ +│ │ P95: 4.5s │ │ P95: 200ms │ │ P95: 0.25 │ │ P95: 2.5s│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 性能趋势(P50 / P75 / P95 / P99) │ │ +│ │ ╭────────────────────────────────────────────────────╮ │ │ +│ │ │ ▲ P99 ── P95 ─ ─ P75 ┄ ┄ P50 │ │ │ +│ │ │ │╲ │ │ │ +│ │ │ │ ╲──────╮ │ │ │ +│ │ │ │ ╲╱╲ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ╰───────────────────────────────────────────────╯ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 页面性能排行 [按 LCP 排序 ▼] │ │ +│ ├─────────────────────┬──────┬──────┬──────┬──────┬───────┤ │ +│ │ 页面 │ LCP │ FID │ CLS │ 样本 │ 评级 │ │ +│ ├─────────────────────┼──────┼──────┼──────┼──────┼───────┤ │ +│ │ /home │ 2.8s │ 80ms │ 0.10 │ 1.2k │ 🟢 良 │ │ +│ │ /product/:id │ 4.2s │ 150ms│ 0.20 │ 800 │ 🔴 差 │ │ +│ │ /checkout │ 3.5s │ 120ms│ 0.15 │ 500 │ 🟡 中 │ │ +│ │ ... │ │ │ │ │ │ │ +│ └─────────────────────┴──────┴──────┴──────┴──────┴───────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.5 告警中心 + +**定位**:配置告警规则,查看告警历史 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 告警中心 │ +│ ┌─────────┐ │ +│ │ 告警规则 │ 告警历史 │ +│ └─────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [+ 新建告警规则] │ +│ │ +│ ┌───┬─────────────────────┬──────────┬───────┬────────┬─────┐ │ +│ │ ✓ │ 规则名称 │ 类型 │ 级别 │ 状态 │ 操作 │ │ +│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │ +│ │ 🔔│ 错误数量突增 │ 突增告警 │ 严重 │ ✅ 启用 │ ... │ │ +│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │ +│ │ 🔔│ 新错误出现 │ 新错误 │ 警告 │ ✅ 启用 │ ... │ │ +│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │ +│ │ 🔔│ LCP 超过 4s │ 阈值告警 │ 警告 │ ✅ 启用 │ ... │ │ +│ └───┴─────────────────────┴──────────┴───────┴────────┴─────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、组件设计 + +### 5.1 指标卡片 + +``` +┌─────────────────┐ +│ 错误总数 │ ← 标题(小字、灰色) +│ 128 │ ← 数值(大字、粗体) +│ ↑ 15% vs 昨日 │ ← 趋势(红涨绿跌) +│ ╱╲ │ ← 迷你趋势图(可选) +│ ╱ ╲ │ +└─────────────────┘ +``` + +**变体**: +- 标准:数字 + 趋势 +- 带评级:数字 + 颜色评级(性能指标用) +- 极简:只有数字 + +### 5.2 状态标签 + +| 状态 | 颜色 | 示例 | +|------|------|------| +| Active(未解决) | 红底白字 | `active` | +| Resolved(已解决) | 绿底白字 | `resolved` | +| Ignored(已忽略) | 灰底灰字 | `ignored` | +| Firing(告警中) | 红底白字 | `FIRING` | +| Resolved(已恢复) | 绿底白字 | `RESOLVED` | + +### 5.3 错误级别标记 + +| 级别 | 颜色 | +|------|------| +| Fatal | 🔴 深红 | +| Error | 🔴 红 | +| Warning | 🟡 黄 | +| Info | 🔵 蓝 | +| Debug | ⚪ 灰 | + +### 5.4 性能评级 + +| 评级 | 颜色 | LCP | FID | CLS | +|------|------|-----|-----|-----| +| Good 🟢 | 绿 | < 2.5s | < 100ms | < 0.1 | +| Needs Improvement 🟡 | 黄 | 2.5-4s | 100-300ms | 0.1-0.25 | +| Poor 🔴 | 红 | > 4s | > 300ms | > 0.25 | + +--- + +## 六、图表设计 + +### 6.1 图表库选型 + +| 库 | 体积 | 功能 | 适合场景 | +|----|------|------|----------| +| Chart.js | ~60KB gzip | 基础图表够用 | 轻量、简单 | +| ECharts | ~150KB gzip | 功能强大 | 复杂图表、交互多 | +| uPlot | ~15KB gzip | 时序图表 | 极致轻量、性能好 | + +**推荐方案**: +- 默认用 Chart.js(够用、轻量、生态好) +- 性能监控页面用 ECharts(需要更复杂的交互) +- 可按需加载,首屏只加载必要的 + +### 6.2 图表类型 + +| 图表 | 用途 | 页面 | +|------|------|------| +| 折线图 | 错误趋势、性能趋势 | 概览、错误、性能 | +| 柱状图 | Top N 排行、分布 | 错误、性能 | +| 饼图 / 环形图 | 占比、分布 | 概览、错误 | +| 面积图 | 堆叠趋势 | 性能 | +| 热力图 | 时间段分布 | 错误 | + +### 6.3 与 Grafana 的关系 + +**管理后台解决的问题**: +- 错误 Issue 管理(状态、分配、标记) +- 项目配置和管理 +- 告警规则配置 +- 更友好的错误详情展示 + +**Grafana 解决的问题**: +- 灵活的自定义仪表盘 +- 复杂的数据探索 +- 多种数据源联合查询 +- 告警(可复用 Grafana Alerting) + +**分工原则**: +- 常用功能做进管理后台(开箱即用) +- 高级分析用 Grafana(灵活强大) +- 管理后台提供一键跳转到 Grafana 的链接 + +--- + +## 七、前端架构 + +### 7.1 目录结构 + +``` +frontend/ # 前端工程(独立目录) +├── public/ +│ └── favicon.ico +├── src/ +│ ├── assets/ # 静态资源 +│ │ ├── images/ +│ │ └── icons/ +│ ├── components/ # 通用组件 +│ │ ├── layout/ # 布局组件 +│ │ │ ├── MainLayout.tsx # 主布局(侧边栏+顶栏+内容) +│ │ │ ├── Sidebar.tsx # 侧边栏 +│ │ │ ├── Header.tsx # 顶部栏 +│ │ │ └── PageHeader.tsx # 页面头 +│ │ ├── chart/ # 图表组件 +│ │ │ ├── LineChart.tsx # 折线图 +│ │ │ ├── BarChart.tsx # 柱状图 +│ │ │ └── PieChart.tsx # 饼图 +│ │ ├── metrics/ # 指标组件 +│ │ │ └── MetricCard.tsx # 指标卡片 +│ │ └── common/ # 其他通用组件 +│ │ ├── StatusTag.tsx # 状态标签 +│ │ └── CopyButton.tsx # 复制按钮 +│ ├── pages/ # 页面 +│ │ ├── overview/ # 概览页 +│ │ │ └── index.tsx +│ │ ├── errors/ # 错误管理 +│ │ │ ├── List.tsx # 错误列表 +│ │ │ └── Detail.tsx # 错误详情 +│ │ ├── performance/ # 性能分析 +│ │ │ └── index.tsx +│ │ ├── network/ # 网络分析 +│ │ │ └── index.tsx +│ │ ├── alerts/ # 告警中心 +│ │ │ ├── Rules.tsx # 告警规则 +│ │ │ └── History.tsx # 告警历史 +│ │ ├── logs/ # 日志查询 +│ │ │ └── index.tsx +│ │ ├── settings/ # 项目设置 +│ │ │ └── index.tsx +│ │ ├── projects/ # 项目管理 +│ │ │ └── List.tsx +│ │ └── integration/ # 接入指南 +│ │ └── index.tsx +│ ├── store/ # 状态管理 (Zustand) +│ │ ├── index.ts # 导出 +│ │ ├── useAppStore.ts # 全局状态(主题、侧边栏等) +│ │ └── useProjectStore.ts # 当前项目状态 +│ ├── services/ # API 服务 +│ │ ├── request.ts # Axios 封装 +│ │ ├── project.ts # 项目相关 API +│ │ ├── error.ts # 错误相关 API +│ │ ├── performance.ts # 性能相关 API +│ │ └── alert.ts # 告警相关 API +│ ├── hooks/ # 自定义 Hooks +│ │ ├── useProject.ts # 当前项目 hook +│ │ ├── useTimeRange.ts # 时间范围 hook +│ │ └── useChartTheme.ts # 图表主题 hook +│ ├── router/ # 路由配置 +│ │ ├── index.tsx # 路由入口 +│ │ └── routes.ts # 路由表 +│ ├── utils/ # 工具函数 +│ │ ├── format.ts # 格式化(时间、数字等) +│ │ ├── date.ts # 日期工具 +│ │ └── storage.ts # 本地存储 +│ ├── types/ # TypeScript 类型定义 +│ │ ├── api.ts # API 类型 +│ │ ├── error.ts # 错误相关类型 +│ │ ├── performance.ts # 性能相关类型 +│ │ └── common.ts # 通用类型 +│ ├── styles/ # 全局样式 +│ │ ├── global.less +│ │ └── variables.less +│ ├── App.tsx # 根组件 +│ └── main.tsx # 入口文件 +├── .eslintrc.js # ESLint 配置 +├── .prettierrc # Prettier 配置 +├── tsconfig.json # TypeScript 配置 +├── vite.config.ts # Vite 配置 +└── package.json +``` + +### 7.2 路由设计 + +使用 React Router v6,嵌套路由 + 懒加载: + +```typescript +// router/routes.ts +const routes = [ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'overview', lazy: () => import('@/pages/overview') }, + { + path: 'errors', + children: [ + { index: true, lazy: () => import('@/pages/errors/List') }, + { path: ':id', lazy: () => import('@/pages/errors/Detail') }, + ], + }, + { path: 'performance', lazy: () => import('@/pages/performance') }, + { path: 'network', lazy: () => import('@/pages/network') }, + { + path: 'alerts', + children: [ + { index: true, element: }, + { path: 'rules', lazy: () => import('@/pages/alerts/Rules') }, + { path: 'history', lazy: () => import('@/pages/alerts/History') }, + ], + }, + { path: 'logs', lazy: () => import('@/pages/logs') }, + { path: 'settings', lazy: () => import('@/pages/settings') }, + { path: 'integration', lazy: () => import('@/pages/integration') }, + ], + }, + { path: '/projects', element: }, + { path: '*', element: }, +]; +``` + +### 7.3 状态管理(Zustand) + +**为什么用 Zustand 而不是 Redux?** +- 比 Redux 简单太多,没有 reducer、action、dispatch 那些概念 +- 体积小(~1KB),性能好 +- TypeScript 支持好 +- 支持 middleware(persist、devtools 等) + +```typescript +// store/useAppStore.ts +import { create } from 'zustand'; + +interface AppState { + theme: 'dark' | 'light'; + sidebarCollapsed: boolean; + currentProject: Project | null; + + toggleTheme: () => void; + toggleSidebar: () => void; + setCurrentProject: (project: Project) => void; +} + +export const useAppStore = create((set) => ({ + theme: 'dark', + sidebarCollapsed: false, + currentProject: null, + + toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })), + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), + setCurrentProject: (project) => set({ currentProject: project }), +})); +``` + +### 7.4 数据请求(TanStack Query) + +**为什么用 TanStack Query?** +- 自动缓存,相同请求不会重复发 +- 后台自动刷新 +- 分页、无限滚动都有封装 +- 乐观更新、重试策略 +- 和 Zustand 互补,不用把 API 数据放全局 store + +```typescript +// hooks/useErrors.ts +import { useQuery } from '@tanstack/react-query'; +import { getErrorList } from '@/services/error'; + +export function useErrorList(params: ErrorListParams) { + return useQuery({ + queryKey: ['errors', params], + queryFn: () => getErrorList(params), + keepPreviousData: true, + staleTime: 30_000, // 30秒内认为是新鲜的 + }); +} +``` + +### 7.5 请求封装(Axios) + +```typescript +// services/request.ts +import axios from 'axios'; +import { message } from 'antd'; + +const request = axios.create({ + baseURL: '/api', + timeout: 15000, +}); + +// 请求拦截器:加 token +request.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 响应拦截器:统一处理错误 +request.interceptors.response.use( + (res) => res.data, + (err) => { + const status = err.response?.status; + const message = err.response?.data?.message || err.message; + + if (status === 401) { + // 登录过期,跳登录页 + } else { + message.error(message); + } + + return Promise.reject(err); + } +); + +export default request; +``` + +### 7.6 主题配置(Ant Design 5 深色主题) + +Ant Design 5 用 CSS-in-JS,通过 ConfigProvider 配置主题: + +```typescript +// App.tsx +import { ConfigProvider, theme as antdTheme } from 'antd'; +import { useAppStore } from '@/store'; + +function App() { + const { theme } = useAppStore(); + + return ( + + + + ); +} +``` + +**自定义暗色主题色板**(贴合 GitHub Dark 风格): +```typescript +{ + colorBgLayout: '#0d1117', // 页面背景 + colorBgContainer: '#161b22', // 卡片背景 + colorBgElevated: '#21262d', // 悬浮背景 + colorBorder: '#30363d', // 边框 + colorText: '#e6edf3', // 主文字 + colorTextSecondary: '#8b949e',// 次文字 + colorTextTertiary: '#6e7681', // 弱文字 +} +``` + +### 7.7 图表封装 + +基于 ECharts 封装通用图表组件,自动适配主题和容器大小: + +```typescript +// components/chart/BaseChart.tsx +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts'; +import { useAppStore } from '@/store'; + +interface BaseChartProps { + option: echarts.EChartsOption; + height?: number | string; + className?: string; +} + +export function BaseChart({ option, height = 300, className }: BaseChartProps) { + const chartRef = useRef(null); + const chartInstance = useRef(null); + const { theme } = useAppStore(); + + useEffect(() => { + if (!chartRef.current) return; + + chartInstance.current = echarts.init( + chartRef.current, + theme === 'dark' ? 'dark' : null + ); + + const resizeObserver = new ResizeObserver(() => { + chartInstance.current?.resize(); + }); + resizeObserver.observe(chartRef.current); + + return () => { + resizeObserver.disconnect(); + chartInstance.current?.dispose(); + }; + }, [theme]); + + useEffect(() => { + chartInstance.current?.setOption(option, true); + }, [option]); + + return
; +} +``` + +### 7.8 构建与部署 + +**开发环境**: +```bash +pnpm dev # 启动开发服务器 +pnpm build # 生产构建 +pnpm preview # 预览构建结果 +pnpm lint # ESLint 检查 +pnpm type-check # TypeScript 类型检查 +``` + +**部署方案**: +- 构建产物:`dist/` 目录 +- Nginx 直接托管静态文件 +- API 请求通过 Nginx 反向代理到后端 +- 和现有 `public/manage/` 共存,新前端走 `/manage/` 路径 + +```nginx +location /manage/ { + try_files $uri $uri/ /manage/index.html; + root /path/to/frontend/dist; +} +``` + +--- + +## 八、交互设计原则 + +### 8.1 快速导航 + +- 全局搜索:`⌘K` 唤起,可搜项目、错误、页面 +- 面包屑:永远知道自己在哪 +- 相关跳转:错误详情里可跳转到对应页面的性能分析 + +### 8.2 数据加载 + +- 骨架屏:数据加载中显示骨架,不跳 +- 增量加载:列表滚动加载 +- 实时刷新:可开/关,默认 30 秒自动刷新 + +### 8.3 操作反馈 + +- 重要操作(删除、标记已解决)有确认弹窗 +- 成功操作顶部弹 toast,3 秒自动消失 +- 操作失败显示错误原因,可重试 + +--- + +## 九、API 设计 + +### 9.1 统计 API + +``` +# 概览数据 +GET /api/projects/{id}/overview?time_range=24h + +# 错误趋势 +GET /api/projects/{id}/errors/trend?time_range=24h&interval=1h + +# 错误列表 +GET /api/projects/{id}/errors?page=1&page_size=20&level=error&status=active + +# 错误详情 +GET /api/projects/{id}/errors/{fingerprint} + +# 性能指标 +GET /api/projects/{id}/performance/metrics?time_range=24h&metric=lcp + +# 告警规则 +GET /api/projects/{id}/alerts/rules +POST /api/projects/{id}/alerts/rules +PUT /api/projects/{id}/alerts/rules/{rule_id} +DELETE /api/projects/{id}/alerts/rules/{rule_id} + +# 告警历史 +GET /api/projects/{id}/alerts/history?page=1&page_size=20 +``` + +--- + +## 十、演进路线 + +### 10.1 当前状态 + +现有的 [index.html](file:///Users/weidingjian/Desktop/work/ai/light-sentry/public/manage/index.html) 是项目列表页,只有最基础的 CRUD 功能。 + +### 10.2 版本规划 + +**v1.1(下一个版本)**: +- 改造成侧边栏 + 内容区的布局 +- 新增概览页(核心指标 + 错误趋势) +- 新增错误列表页(从聚合表读) +- 项目选择器移到顶部 + +**v1.2**: +- 错误详情页 +- 性能分析页 +- 告警规则管理 + +**v2.0**: +- 完整的错误管理工作流 +- 性能深入分析(瀑布图、资源时序) +- 用户会话追踪 diff --git a/docs/06-deployment.md b/docs/06-deployment.md new file mode 100644 index 0000000..104709e --- /dev/null +++ b/docs/06-deployment.md @@ -0,0 +1,544 @@ +# 部署与运维设计文档 + +## 一、部署方式 + +### 1.1 Docker Compose(推荐) + +一键部署,适合中小团队。 + +``` +docker compose up -d +``` + +包含的服务: + +| 服务 | 镜像 | 端口 | 说明 | +|------|------|------|------| +| light-sentry-api | 自建 (Node.js) | 9000 | API 服务 + 管理后台 | +| loki | grafana/loki | 3100 | 日志存储 | +| promtail | grafana/promtail | - | 日志收集(容器日志) | +| grafana | grafana/grafana | 3000 | 数据可视化 | +| dozzle | amir20/dozzle | 8080 | 容器日志查看 | +| nginx | (系统已有) | 80/443 | 反向代理 + SSL | + +### 1.2 二进制部署 + +适合对 Docker 不熟悉的场景: + +1. 安装 Node.js 18+ +2. 安装 Loki(单二进制) +3. 安装 Grafana +4. 配置 Nginx +5. 启动 API 服务(pm2 守护) + +### 1.3 Kubernetes 部署 + +适合大规模、多租户场景: + +- Helm Chart 部署 +- HPA 自动扩缩容 +- Loki 分布式模式 +- MySQL / PostgreSQL 集群 + +--- + +## 二、资源需求 + +### 2.1 最小配置(测试/个人用) + +| 资源 | 配置 | 说明 | +|------|------|------| +| CPU | 1 核 | 低流量下足够 | +| 内存 | 1GB | 紧张,可能需要调小 Loki 缓存 | +| 磁盘 | 20GB | 存 7 天日志(每天 ~2GB) | +| 网络 | 1Mbps | 上报流量不大 | + +**承载能力**: +- 10 个项目以内 +- 每天 1 万事件以内 + +### 2.2 推荐配置(小团队) + +| 资源 | 配置 | 说明 | +|------|------|------| +| CPU | 2 核 | 应对突发流量 | +| 内存 | 4GB | Loki + API + Grafana 都够 | +| 磁盘 | 100GB SSD | 存 30 天日志 | +| 网络 | 5Mbps | 足够 | + +**承载能力**: +- 50 个项目以内 +- 每天 100 万事件以内 + +### 2.3 生产配置(中大型团队) + +| 资源 | 配置 | 说明 | +|------|------|------| +| CPU | 4 核以上 | 高并发上报 | +| 内存 | 8GB 以上 | Loki 内存索引 | +| 磁盘 | 500GB+ SSD | 大量日志存储 | +| 网络 | 10Mbps+ | 大流量上报 | + +**承载能力**: +- 200 个项目以内 +- 每天 1000 万事件以内 + +--- + +## 三、Docker Compose 配置详解 + +### 3.1 完整配置说明 + +```yaml +version: '3.8' + +services: + # API 服务 + 管理后台 + light-sentry-api: + build: . + container_name: light-sentry-api + network_mode: host + environment: + - NODE_ENV=production + - PORT=9000 + - LOKI_URL=http://127.0.0.1:3100 + - DATA_DIR=/app/data + volumes: + - ./data:/app/data + restart: unless-stopped + depends_on: + - loki + + # Loki - 日志存储 + loki: + image: grafana/loki:2.9.4 + container_name: light-sentry-loki + network_mode: host + command: -config.file=/etc/loki/local-config.yaml + volumes: + - ./loki/config:/etc/loki + - loki-data:/loki + restart: unless-stopped + + # Promtail - 收集容器日志 + promtail: + image: grafana/promtail:2.9.4 + container_name: light-sentry-promtail + network_mode: host + volumes: + - ./promtail/config:/etc/promtail + - /var/lib/docker/containers:/var/lib/docker/containers:ro + restart: unless-stopped + depends_on: + - loki + + # Grafana - 可视化 + grafana: + image: grafana/grafana:10.2.0 + container_name: light-sentry-grafana + network_mode: host + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin123 + volumes: + - grafana-data:/var/lib/grafana + restart: unless-stopped + + # Dozzle - 容器日志查看 + dozzle: + image: amir20/dozzle:latest + container_name: light-sentry-dozzle + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped + +volumes: + loki-data: + grafana-data: +``` + +### 3.2 Loki 配置 + +`loki/config/local-config.yaml` + +```yaml +auth_enabled: false + +server: + http_listen_port: 3100 + +ingester: + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: 1 + chunk_idle_period: 30m + chunk_retain_period: 1m + max_transfer_retries: 0 + +schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: filesystem + schema: v12 + index: + prefix: index_ + period: 24h + +storage_config: + boltdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/index_cache + shared_store: filesystem + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 168h # 7 天 + per_stream_rate_limit: 10MB + ingestion_rate_mb: 50 + ingestion_burst_size_mb: 100 + max_streams_per_user: 10000 + max_chunks_per_query: 2000000 + max_query_series: 5000 + +table_manager: + retention_deletes_enabled: true + retention_period: 168h # 7 天 +``` + +--- + +## 四、Nginx 配置 + +### 4.1 反向代理配置 + +```nginx +server { + listen 443 ssl http2; + server_name log.example.com; + + # SSL 配置 + ssl_certificate /path/to/fullchain.pem; + ssl_certificate_key /path/to/privkey.pem; + + # API 服务(Sentry 上报 + 管理后台 API) + location /api/ { + proxy_pass http://127.0.0.1:9000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 上报接口超时 + proxy_read_timeout 30s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + + # CORS(SDK 上报需要) + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods GET,POST,OPTIONS always; + add_header Access-Control-Allow-Headers Content-Type,X-Sentry-Auth always; + if ($request_method = OPTIONS) { + return 204; + } + } + + # 管理后台 + location /manage/ { + proxy_pass http://127.0.0.1:9000/manage/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Grafana + location /grafana/ { + proxy_pass http://127.0.0.1:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Dozzle + location /dozzle/ { + proxy_pass http://127.0.0.1:8080/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支持 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Loki(可选,直接暴露给 Grafana 用,一般不需要外部访问) + location /loki/ { + proxy_pass http://127.0.0.1:3100/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + deny all; # 禁止外部访问,只允许内网 + } + + # 根路径重定向到管理后台 + location = / { + return 301 /manage/; + } +} +``` + +### 4.2 限流配置 + +```nginx +# 定义限流 zone +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s; + +location /api/ { + limit_req zone=api_limit burst=200 nodelay; + # ... 其他配置 +} +``` + +--- + +## 五、CI/CD 部署 + +### 5.1 Gitea Actions 示例 + +```yaml +name: Deploy Light-Sentry + +on: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd ~/light-sentry + git pull + docker compose build light-sentry-api + docker compose up -d + echo "Deploy completed" +``` + +### 5.2 智能重启(只重启变更的服务) + +检测变更的文件,只重启相关服务,减少停机时间: + +- `src/**` → 重启 API 服务 +- `loki/**` → 重启 Loki +- `grafana/**` → 重启 Grafana +- `docker-compose.yml` → 全部重启 +- `nginx/**` → reload Nginx + +--- + +## 六、监控与告警 + +### 6.1 自身监控 + +Light-Sentry 自己也要监控自己: + +| 监控项 | 方式 | 告警阈值 | +|--------|------|----------| +| API 服务存活 | 健康检查接口 + Prometheus | 502 持续 1 分钟 | +| Loki 服务存活 | Grafana 内置监控 | 连接失败 | +| 磁盘使用率 | Node Exporter | > 80% 告警 | +| 内存使用率 | Node Exporter | > 85% 告警 | +| CPU 使用率 | Node Exporter | > 90% 告警 | +| 事件上报量 | Loki 查询 | 突增 200% | +| 错误率 | Loki 查询 | > 5% | + +### 6.2 健康检查 + +``` +GET /health + +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00Z", + "uptime": 86400, + "queue_size": 123, + "loki": "connected", + "mysql": "connected" +} +``` + +--- + +## 七、备份与恢复 + +### 7.1 需要备份的数据 + +| 数据 | 位置 | 备份频率 | 保留时间 | +|------|------|----------|----------| +| 项目配置 | data/projects.json | 每天 | 30 天 | +| Loki 数据 | loki-data 卷 | 每周 | 4 周 | +| Grafana 配置 | grafana-data 卷 | 每天 | 30 天 | +| 告警规则 | MySQL | 每天 | 永久 | +| 错误聚合 | MySQL | 每天 | 永久 | + +### 7.2 备份脚本 + +```bash +#!/bin/bash +# backup.sh - Light-Sentry 备份脚本 + +BACKUP_DIR="/backup/light-sentry" +DATE=$(date +%Y%m%d_%H%M%S) + +# 创建备份目录 +mkdir -p $BACKUP_DIR + +# 备份配置文件 +tar czf $BACKUP_DIR/config_$DATE.tar.gz /opt/light-sentry/data/ + +# 备份 Loki 数据(可选,量大) +# docker run --rm -v loki-data:/data -v $BACKUP_DIR:/backup \ +# alpine tar czf /backup/loki_$DATE.tar.gz -C /data . + +# 备份 Grafana +docker run --rm -v grafana-data:/data -v $BACKUP_DIR:/backup \ + alpine tar czf /backup/grafana_$DATE.tar.gz -C /data . + +# 清理 30 天前的备份 +find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete + +echo "Backup completed: $DATE" +``` + +### 7.3 恢复流程 + +```bash +# 1. 停止服务 +docker compose down + +# 2. 恢复配置 +tar xzf backup/config_20240101_000000.tar.gz -C /opt/light-sentry/ + +# 3. 恢复 Grafana +docker run --rm -v grafana-data:/data -v /backup:/backup \ + alpine tar xzf /backup/grafana_20240101_000000.tar.gz -C /data + +# 4. 启动服务 +docker compose up -d +``` + +--- + +## 八、性能优化 + +### 8.1 API 服务优化 + +- **Node.js 集群模式**:利用多核 CPU +- **内存队列调优**:根据流量调整队列大小和刷盘频率 +- **连接池**:数据库连接池、HTTP 连接池 +- **缓存**:项目配置缓存,不用每次查存储 + +### 8.2 Loki 优化 + +- **调整保留时间**:从 7 天调整到实际需要的时间 +- **降低索引基数**:减少 label 数量 +- **批量写入**:增加 chunk 大小,减少写入次数 +- **内存调优**:根据实际数据量调整内存 + +### 8.3 Nginx 优化 + +- **启用 gzip 压缩**:减少传输体积 +- **启用缓存**:静态文件缓存 +- **TCP 参数调优**:增加连接数限制 + +--- + +## 九、安全加固 + +### 9.1 网络安全 + +- 管理后台加访问密码(HTTP Basic Auth 或登录) +- Loki 不对外暴露(只允许 Grafana 访问) +- Dozzle 加访问密码 +- 只开放必要端口(80/443) + +### 9.2 数据安全 + +- SDK 上报接口 CORS 限制域名(可选) +- 项目 DSN 泄露后可重置 publicKey +- 敏感数据自动脱敏 +- 定期备份,异地存储 + +### 9.3 依赖安全 + +- 定期更新依赖 +- 镜像扫描漏洞 +- 最小权限运行 + +--- + +## 十、常见问题排查 + +### 10.1 事件上报失败 + +1. 检查网络是否通:`curl -I https://log.example.com/api/1001/envelope/` +2. 检查 DSN 是否正确:publicKey 和 projectId 对应 +3. 查看 API 日志:`docker logs light-sentry-api` +4. 查看 Nginx 日志:`tail -f /var/log/nginx/access.log` + +### 10.2 Grafana 查不到数据 + +1. 检查 Loki 数据源是否配置正确 +2. 检查时间范围是否正确 +3. 检查 label 拼写是否正确 +4. 查看 Loki 日志:`docker logs light-sentry-loki` + +### 10.3 服务无法启动 + +1. 检查端口是否被占用:`netstat -tlnp | grep 9000` +2. 查看容器日志:`docker logs light-sentry-api` +3. 检查配置文件格式:JSON / YAML 语法 +4. 检查磁盘空间:`df -h` + +--- + +## 十一、升级策略 + +### 11.1 版本升级 + +```bash +# 拉取最新代码 +git pull + +# 重新构建并启动 +docker compose build light-sentry-api +docker compose up -d + +# 验证 +curl https://log.example.com/health +``` + +### 11.2 数据迁移 + +- 配置向后兼容,新版本能读旧版本数据 +- 启动时自动检测并执行数据迁移 +- 迁移前自动备份 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2df7f86 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,577 @@ +# Light-Sentry 日志分析系统设计文档 + +## 1. 项目概述 + +### 1.1 背景 +基于用户需求,需要构建一个日志分析系统,支持接收 Sentry 前后端 SDK 的上报,并在 **2核2G ECS + 阿里云OSS** 的资源限制下稳定运行。 + +### 1.2 核心目标 +- **兼容性**:完全兼容 Sentry SDK 的上报协议(DSN、事件格式) +- **轻量级**:适配 2核2G 服务器资源 +- **低成本**:利用阿里云 OSS 进行冷存储,降低成本 +- **可视化**:提供友好的日志查询和可视化界面 + +--- + +## 2. 系统架构 + +### 2.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 客户端 SDK 层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Browser SDK │ │ Node SDK │ │ Python SDK │ │ +│ │ @sentry/browser │ @sentry/node │ @sentry/python │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └─────────────────┼─────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ Sentry API 网关 │ │ +│ │ /api/{project}/store │ │ +│ └───────────┬───────────┘ │ +└───────────────────────────┼─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 服务端处理层 │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ 事件处理器 │──▶│ 事件转换器 │ │ +│ │ (Event Handler)│ │ (Event Converter)│ │ +│ └──────────────────┘ └──────────┬───────┘ │ +│ │ │ +│ ┌───────────────────────┼───────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ Loki 索引 │ │ Loki 存储 │ │ OSS 持久化 │ │ +│ │ (索引元数据) │ │ (近期日志) │ │ (历史归档) │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 可视化查询层 │ +│ ┌───────────────┐ │ +│ │ Grafana │ │ +│ │ (查询/图表) │ │ +│ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 核心组件说明 + +| 组件 | 技术选型 | 角色 | 资源占用 | +|------|---------|------|---------| +| **API网关** | Express/FastAPI | 接收Sentry协议上报 | ~100MB | +| **日志存储** | Loki | 索引和近期日志存储 | ~500MB | +| **持久化层** | 阿里云OSS | 历史日志归档 | 按需扩展 | +| **可视化** | Grafana | 日志查询和图表展示 | ~300MB | + +**总内存预估**:~1GB(保留1GB给系统和缓冲) + +--- + +## 3. 技术方案 + +### 3.1 Sentry 协议兼容层 + +#### 3.1.1 协议说明 + +Sentry SDK 使用 DSN (Data Source Name) 配置上报地址: +``` +{PROTOCOL}://{PUBLIC_KEY}@{HOST}/{PATH}/{PROJECT_ID} +``` + +SDK 向 `/api/{PROJECT_ID}/store/` 端点发送 POST 请求,数据格式为 JSON。 + +#### 3.1.2 事件数据结构(简化版) + +```json +{ + "event_id": "abc123...", + "timestamp": "2024-01-01T00:00:00Z", + "level": "error", + "logger": "javascript", + "platform": "javascript", + "message": "Uncaught TypeError: Cannot read property", + "exception": { + "values": [{ + "type": "TypeError", + "value": "Cannot read property 'x' of undefined", + "stacktrace": { + "frames": [{ + "filename": "app.js", + "lineno": 42, + "function": "doSomething" + }] + } + }] + }, + "tags": { + "environment": "production", + "release": "v1.0.0" + }, + "contexts": { + "request": { + "url": "https://example.com/page", + "method": "GET" + }, + "user": { + "id": "12345", + "email": "user@example.com" + } + } +} +``` + +#### 3.1.3 API 网关设计 + +**技术选型**:Node.js + Express(轻量级,内存占用低) + +**核心端点**: + +| 端点 | 方法 | 功能 | +|------|------|------| +| `/api/:projectId/store/` | POST | 接收 Sentry SDK 上报事件 | +| `/health` | GET | 健康检查 | + +**设计要点**: +- 支持 Sentry 的 DSN 认证(通过 `X-Sentry-Auth` 头或查询参数) +- 异步处理,不阻塞请求响应 +- 事件格式校验和标准化 +- 支持批量上报 + +### 3.2 日志存储方案 + +#### 3.2.1 Loki 配置 + +**为什么选择 Loki**: +- 轻量级,内存占用远低于 ELK(Elasticsearch 需要至少 4GB) +- 基于标签索引,查询效率高 +- 与 Grafana 无缝集成 +- 支持 OSS 作为后端存储 + +**关键配置**: + +```yaml +auth_enabled: false + +server: + http_listen_port: 3100 + +ingester: + lifecycler: + address: 127.0.0.1 + ring: + kvstore: + store: inmemory + replication_factor: 1 + final_sleep: 0s + chunk_idle_period: 1h + chunk_retain_period: 30s + max_transfer_retries: 0 + +schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: aws + schema: v11 + index: + prefix: loki_index_ + period: 24h + +storage_config: + aws: + s3: s3://{AK}:{SK}@{REGION}/{BUCKET} + s3forcepathstyle: true + boltdb_shipper: + active_index_directory: /data/loki/index + cache_location: /data/loki/cache + shared_store: s3 + +limits_config: + reject_old_samples: true + reject_old_samples_max_age: 168h + +chunk_store_config: + max_look_back_period: 0s + +table_manager: + retention_deletes_enabled: true + retention_period: 30d +``` + +#### 3.2.2 OSS 存储策略 + +**存储层级**: + +| 层级 | 用途 | 存储类型 | 成本 | +|------|------|---------|------| +| **标准存储** | 近期日志(30天内) | OSS标准 | 0.12元/GB/月 | +| **低频存储** | 中期日志(30-90天) | OSS低频 | 0.07元/GB/月 | +| **归档存储** | 历史日志(90天以上) | OSS归档 | 0.014元/GB/月 | + +**生命周期管理**: +```xml + + + transition-to-infrequent-access + + loki/ + + Enabled + + 30 + IA + + + 90 + Archive + + + 365 + + + +``` + +### 3.3 事件处理流程 + +``` +SDK上报 → API网关 → 格式校验 → 标签提取 → Loki写入 → OSS持久化 + ↓ + 索引元数据存储 +``` + +**标签提取策略**(用于 Loki 查询): + +| 标签名 | 来源字段 | 用途 | +|--------|---------|------| +| `project` | `project_id` | 项目隔离 | +| `environment` | `tags.environment` | 环境区分 | +| `level` | `level` | 日志级别过滤 | +| `platform` | `platform` | 平台分类 | +| `release` | `tags.release` | 版本追踪 | +| `logger` | `logger` | 日志来源 | + +### 3.4 可视化方案 + +#### 3.4.1 Grafana 配置 + +**数据源配置**: +- 类型:Loki +- URL:`http://localhost:3100` +- 认证:无 + +**查询示例**: + +```logql +// 查询所有 error 级别日志 +{level="error"} |= "error" + +// 查询特定项目的日志 +{project="my-project", environment="production"} + +// 查询包含特定错误信息的日志 +{level="error"} |= "TypeError" + +// 统计错误数量 +count_over_time({level="error"}[1m]) +``` + +**仪表盘设计**: + +| 面板 | 功能 | 查询 | +|------|------|------| +| 错误趋势 | 实时错误数量折线图 | `count_over_time({level="error"}[1m])` | +| 环境分布 | 各环境错误占比饼图 | `sum(count_over_time({level="error"}[1h])) by (environment)` | +| 平台分布 | 各平台错误占比饼图 | `sum(count_over_time({level="error"}[1h])) by (platform)` | +| 最新错误 | 最近错误列表 | `{level="error"} | tail 10` | +| Top错误类型 | 错误类型排名 | `topk(5, count by (exception_type) (count_over_time({level="error"}[1h])))` | + +--- + +## 4. 部署方案 + +### 4.1 服务器配置 + +**ECS 规格**:2核2G(阿里云 ecs.g6.large 或同等规格) + +**操作系统**:Ubuntu 22.04 LTS + +**磁盘配置**: +- 系统盘:40GB SSD +- 数据盘:20GB SSD(用于 Loki 索引缓存) + +### 4.2 资源分配 + +| 组件 | CPU | 内存 | +|------|-----|------| +| 系统预留 | 0.5核 | 512MB | +| API网关 (Node.js) | 0.5核 | 256MB | +| Loki | 1核 | 768MB | +| Grafana | 0.5核 | 512MB | + +### 4.3 Docker Compose 部署 + +```yaml +version: '3.8' + +services: + light-sentry-api: + image: light-sentry/api:latest + ports: + - "9000:9000" + environment: + - LOKI_URL=http://loki:3100 + - OSS_ENDPOINT=${OSS_ENDPOINT} + - OSS_ACCESS_KEY=${OSS_ACCESS_KEY} + - OSS_SECRET_KEY=${OSS_SECRET_KEY} + - OSS_BUCKET=${OSS_BUCKET} + depends_on: + - loki + restart: unless-stopped + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + + loki: + image: grafana/loki:2.9.0 + ports: + - "3100:3100" + volumes: + - ./loki/config:/etc/loki + - ./loki/data:/data/loki + environment: + - AWS_ACCESS_KEY_ID=${OSS_ACCESS_KEY} + - AWS_SECRET_ACCESS_KEY=${OSS_SECRET_KEY} + restart: unless-stopped + deploy: + resources: + limits: + cpus: '1' + memory: 768M + + grafana: + image: grafana/grafana:10.2.0 + ports: + - "3000:3000" + volumes: + - ./grafana/data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} + depends_on: + - loki + restart: unless-stopped + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M +``` + +### 4.4 Nginx 反向代理 + +```nginx +server { + listen 80; + server_name logs.example.com; + + location /api/ { + proxy_pass http://localhost:9000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 10m; + } + + location /grafana/ { + proxy_pass http://localhost:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +## 5. SDK 接入指南 + +### 5.1 前端 SDK 配置 + +```javascript +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + dsn: 'https://public_key@logs.example.com/api/1/store/', + environment: 'production', + release: 'v1.0.0', + tracesSampleRate: 1.0, +}); +``` + +### 5.2 后端 SDK 配置(Node.js) + +```javascript +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public_key@logs.example.com/api/2/store/', + environment: 'production', + release: 'v1.0.0', + tracesSampleRate: 1.0, +}); +``` + +### 5.3 后端 SDK 配置(Python) + +```python +import sentry_sdk + +sentry_sdk.init( + dsn="https://public_key@logs.example.com/api/3/store/", + environment="production", + release="v1.0.0", + traces_sample_rate=1.0, +) +``` + +--- + +## 6. 性能优化策略 + +### 6.1 内存优化 + +| 策略 | 说明 | +|------|------| +| Loki 使用 OSS 作为后端 | 减少本地磁盘和内存占用 | +| API网关使用流处理 | 避免一次性加载大请求 | +| 限制并发连接数 | 使用 Nginx 限流 | + +### 6.2 存储优化 + +| 策略 | 说明 | +|------|------| +| OSS 生命周期管理 | 自动降级存储类型 | +| Loki 压缩 | 启用 Snappy 压缩 | +| 日志采样 | 高频日志采样处理 | + +### 6.3 查询优化 + +| 策略 | 说明 | +|------|------| +| 标签索引 | 使用标签过滤而非全文搜索 | +| 查询缓存 | Grafana 缓存查询结果 | +| 时间范围限制 | 默认查询最近24小时 | + +--- + +## 7. 监控与告警 + +### 7.1 健康检查 + +| 检查项 | 端点 | 频率 | +|--------|------|------| +| API网关 | `/health` | 30秒 | +| Loki | `/ready` | 30秒 | +| Grafana | `/api/health` | 30秒 | + +### 7.2 告警规则 + +| 告警项 | 条件 | 通知方式 | +|--------|------|---------| +| API错误率 | 错误率 > 5% | 钉钉/邮件 | +| 服务不可用 | 健康检查失败 | 钉钉/邮件 | +| 内存使用率 | 内存 > 85% | 钉钉/邮件 | +| OSS存储告警 | 存储 > 80% | 阿里云告警 | + +--- + +## 8. 安全策略 + +### 8.1 认证授权 + +- API 网关支持 Sentry DSN 认证 +- Grafana 启用基础认证 +- Nginx 配置访问控制列表 + +### 8.2 数据加密 + +- 传输层:HTTPS/TLS 1.2+ +- 存储层:OSS 服务端加密(SSE-KMS) + +### 8.3 访问日志 + +- Nginx 访问日志记录 +- API 网关请求日志 + +--- + +## 9. 成本预估 + +### 9.1 服务器成本 + +| 资源 | 规格 | 月费用 | +|------|------|--------| +| ECS | 2核2G | ~80元 | +| 数据盘 | 20GB SSD | ~10元 | +| **合计** | - | **~90元/月** | + +### 9.2 OSS 存储成本 + +假设日日志量:1GB + +| 存储类型 | 容量 | 月费用 | +|----------|------|--------| +| 标准存储 | 30GB | ~3.6元 | +| 低频存储 | 60GB | ~4.2元 | +| 归档存储 | 275GB | ~3.85元 | +| **合计** | - | **~11.65元/月** | + +### 9.3 总成本 + +**~100元/月**(含服务器和存储) + +--- + +## 10. 扩展规划 + +### 10.1 短期(3个月内) + +- 完成核心功能开发 +- 集成 Sentry 协议 +- 部署上线 + +### 10.2 中期(6个月内) + +- 增加日志采样功能 +- 优化查询性能 +- 增加告警功能 + +### 10.3 长期(1年内) + +- 支持更多日志来源 +- 增加分布式追踪 +- 考虑升级到更强配置 + +--- + +## 附录:组件版本建议 + +| 组件 | 推荐版本 | 备注 | +|------|---------|------| +| Node.js | 20.x LTS | API网关运行环境 | +| Express | 4.x | API框架 | +| Loki | 2.9.x | 日志存储 | +| Grafana | 10.x | 可视化 | +| Docker | 24.x | 容器化部署 | + +--- + +**文档版本**: v1.0 +**创建日期**: 2026-06-16 +**适用场景**: 2核2G ECS + 阿里云OSS 资源受限环境 \ No newline at end of file diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..766c182 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,658 @@ +# 业务接入指南 + +本文档说明如何将前端/后端业务接入 light-sentry 日志分析系统。 + +--- + +## 目录 + +1. [系统架构](#1-系统架构) +2. [接入方式概览](#2-接入方式概览) +3. [前端接入(Browser SDK)](#3-前端接入browser-sdk) +4. [后端接入(Node.js SDK)](#4-后端接入nodejs-sdk) +5. [Python 接入](#5-python-接入) +6. [容器日志接入](#6-容器日志接入) +7. [项目与容器关联](#7-项目与容器关联) +8. [一键部署业务机](#8-一键部署业务机) +9. [常见问题](#9-常见问题) + +--- + +## 1. 系统架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ log.starseekai.cn │ +│ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ /manage/ │ │ /grafana/ │ │ /dozzle/ │ │ +│ │ 项目管理 │ │ 仪表盘 │ │ 容器日志查看 │ │ +│ └────┬─────┘ └─────┬──────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ┌────▼────────────────▼────────────────▼───────┐ │ +│ │ Nginx 反向代理 │ │ +│ │ /api/* → API 服务 (9000) │ │ +│ │ /loki/* → Loki (3100) │ │ +│ └────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌────────────────▼───────────────────────┐ │ +│ │ Light-Sentry API 服务 │ │ +│ │ - Sentry SDK 兼容网关 │ │ +│ │ - 项目管理 API │ │ +│ │ - 一键部署 Agent │ │ +│ └────────┬──────────────┬─────────────────┘ │ +│ │ │ │ +│ ┌────────▼───┐ ┌───────▼──────┐ ┌─────────────┐ │ +│ │ Loki │ │ Promtail │ │ Dozzle │ │ +│ │ 日志存储 │ │ 中心日志采集 │ │ 容器日志查看 │ │ +│ └─────────────┘ └──────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +业务机部署: +┌─────────────────────────────────────────────────┐ +│ 业务 ECS 服务器 │ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Promtail │───►│ 中心 Loki │ │ +│ │ (采集容器日志) │ │ log.starseekai.cn│ │ +│ └──────────────┘ └──────────────────┘ │ +│ │ +│ ┌──────────────┐ │ +│ │ Dozzle │───► 远程连接中心 Dozzle Agent │ +│ │ (本地容器日志) │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +### 服务入口 + +| 服务 | 地址 | 说明 | +|------|------|------| +| 项目管理 | `https://log.starseekai.cn/manage/` | 可视化管理项目、容器关联、一键部署 | +| Grafana 仪表盘 | `https://log.starseekai.cn/grafana/` | 查询 Sentry 日志、容器日志、链路追踪 | +| Dozzle 容器日志 | `https://log.starseekai.cn/dozzle/` | 实时查看业务机 Docker 容器日志 | +| Loki 查询 API | `https://log.starseekai.cn/loki/loki/api/v1/` | Loki 原生查询 API | +| SDK 上报地址 | `https://log.starseekai.cn/api/{projectId}/store/` | Sentry SDK 上报端点 | + +--- + +## 2. 接入方式概览 + +light-sentry 完全兼容 Sentry SDK 协议,只需将 SDK 的 **DSN(Data Source Name)** 指向本系统即可。 + +### 接入矩阵 + +| 场景 | 推荐 SDK | 支持协议 | +|------|---------|---------| +| 前端浏览器 | Sentry Browser SDK | Store API + Envelope | +| Node.js 后端服务 | Sentry Node SDK | Store API + Envelope | +| Python 后端服务 | Sentry Python SDK | Store API + Envelope | +| Java Spring Boot | Sentry Java SDK | Store API + Envelope | +| Go 服务 | Sentry Go SDK | Store API + Envelope | + +### DSN 格式 + +Sentry SDK 要求 DSN 格式为: + +``` +https://{public_key}@{host}/{projectId} +``` + +| 参数 | 说明 | 示例 | +|------|------|------| +| `public_key` | 认证公钥(可自定义,建议用项目名) | `order-frontend`、`backend-api` | +| `host` | 本系统的入口地址 | `log.starseekai.cn` | +| `projectId` | **必须是纯数字** | `1001`、`1002`、`1003` | + +**重要:** Sentry SDK 要求 `projectId` 必须是纯数字,不支持字母或字符串 ID。 + +**示例 DSN:** + +```bash +# 前端项目 +https://order-frontend@log.starseekai.cn/1001 + +# 后端项目 +https://order-backend@log.starseekai.cn/1002 + +# Python 服务 +https://payment-service@log.starseekai.cn/1003 +``` + +### 接入流程 + +``` +1. 在项目管理页面 (https://log.starseekai.cn/manage/) 创建项目 +2. 填写纯数字项目 ID(如 1001) +3. 填写 Public Key(可自定义,或留空自动生成) +4. 获取生成的 DSN +5. 在业务代码中配置 SDK +6. 容器日志:通过一键部署或手动配置 Promtail +7. 在 Grafana 仪表盘查看日志 +``` + +### 接入建议:前后端分开 + +**建议将前端和后端作为两个独立项目接入:** + +``` +业务系统 +├── 前端项目 → projectId: 1001, publicKey: myapp-frontend +└── 后端项目 → projectId: 1002, publicKey: myapp-backend +``` + +**分开的好处:** + +| 维度 | 说明 | +|------|------| +| **日志区分** | 前后端错误分开,一目了然 | +| **采样率** | 前端可设置更高采样(用户行为重要),后端可更低(日志量大) | +| **告警规则** | 前端错误率 > 1% 告警,后端 > 5% 告警 | +| **链路追踪** | 通过 trace_id 仍能串联前后端日志 | + +--- + +## 3. 前端接入(Browser SDK) + +### 安装 + +```bash +npm install @sentry/browser +# 或 +yarn add @sentry/browser +# 或 +pnpm add @sentry/browser +``` + +### 基础接入(HTML 单页) + +```html + + +``` + +### React / Vue / Next.js 接入 + +**React(Create React App / Next.js):** + +```javascript +import * as Sentry from '@sentry/browser'; +import { BrowserTracing } from '@sentry/tracing'; + +Sentry.init({ + dsn: 'https://frontend@log.starseekai.cn/1001', + integrations: [new BrowserTracing()], + tracesSampleRate: 0.1, + environment: process.env.NODE_ENV, + release: process.env.npm_package_version, +}); +``` + +**Vue 3:** + +```javascript +import { createApp } from 'vue'; +import * as Sentry from '@sentry/browser'; +import { VueIntegration } from '@sentry/integrations'; + +Sentry.init({ + dsn: 'https://frontend@log.starseekai.cn/1001', + integrations: [new VueIntegration({ app })], + tracesSampleRate: 0.1, + environment: process.env.NODE_ENV, +}); +``` + +### 手动上报错误 + +```javascript +// 手动捕获并上报 +try { + // 业务代码 + JSON.parse('not valid json'); +} catch (err) { + Sentry.captureException(err, { + // 自定义额外数据 + extra: { + userId: currentUser.id, + action: 'checkout', + }, + }); +} + +// 上报自定义消息 +Sentry.captureMessage('用户注册成功', { + level: 'info', + extra: { userId: 12345, plan: 'pro' }, +}); +``` + +### 添加用户上下文 + +```javascript +// 登录时设置 +Sentry.setUser({ + id: 'user_12345', + email: 'user@example.com', + username: 'john_doe', + ip_address: '{{auto}}', // 自动采集 IP +}); + +// 登出时清除 +Sentry.setUser(null); +``` + +--- + +## 4. 后端接入(Node.js SDK) + +### 安装 + +```bash +npm install @sentry/node +# 或 +yarn add @sentry/node +``` + +### Express 接入 + +```javascript +const express = require('express'); +const Sentry = require('@sentry/node'); +const { ExpressIntegration } = require('@sentry/integrations'); + +const app = express(); + +// Sentry 必须在其他中间件之前初始化 +Sentry.init({ + dsn: 'https://backend@log.starseekai.cn/1002', + integrations: [new ExpressIntegration({ app })], + tracesSampleRate: 0.1, + environment: process.env.NODE_ENV, + release: process.env.npm_package_version, +}); + +// 请求处理中间件(必须) +app.use(Sentry.Handlers.requestHandler()); + +// 你的业务路由 +app.get('/api/order', (req, res) => { + // 业务代码 + res.json({ orderId: 'ORD_12345' }); +}); + +// 错误处理中间件(必须) +app.use(Sentry.Handlers.errorHandler()); + +app.listen(3000); +``` + +### 自动捕获未处理错误和 Promise rejections + +```javascript +// 在 Sentry.init() 之后添加 + +// 捕获未处理的 Promise rejection +process.on('unhandledRejection', (reason, promise) => { + Sentry.captureException(reason); +}); + +// 捕获未处理的同步异常 +process.on('uncaughtException', (err) => { + Sentry.captureException(err); + process.exit(1); +}); +``` + +### 手动上报错误 + +```javascript +const Sentry = require('@sentry/node'); + +async function createOrder(req, res) { + try { + const order = await db.orders.create(req.body); + Sentry.addBreadcrumb({ + category: 'db', + message: 'Order created', + data: { orderId: order.id }, + }); + res.json(order); + } catch (err) { + Sentry.captureException(err, { + // 附加请求上下文 + contexts: { + request: { + method: req.method, + url: req.url, + headers: req.headers, + }, + }, + }); + throw err; // 仍需抛出,让 Express 错误中间件处理 + } +} +``` + +--- + +## 5. Python 接入 + +### 安装 + +```bash +pip install sentry-sdk +``` + +### Django / Flask 接入 + +**Django:** + +```python +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + +sentry_sdk.init( + dsn='https://python-service@log.starseekai.cn/1003', + integrations=[DjangoIntegration()], + traces_sample_rate=0.1, + environment='production', + release='v1.2.3', +) +``` + +**Flask:** + +```python +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration + +sentry_sdk.init( + dsn='https://python-service@log.starseekai.cn/1003', + integrations=[FlaskIntegration()], + traces_sample_rate=0.1, + environment='production', +) +``` + +### 手动上报 + +```python +from sentry_sdk import capture_exception, capture_message + +try: + 1 / 0 +except Exception as e: + capture_exception(e) + +capture_message('用户登录成功', level='info', extra={'user_id': 12345}) +``` + +--- + +## 6. 容器日志接入 + +容器日志通过 Promtail 采集,存储到 Loki,可在 Grafana 和 Dozzle 中查看。 + +### 6.1 中心 Promtail(采集本机日志) + +适用于日志分析系统所在的 ECS 服务器。 + +编辑 `promtail/promtail-config.yml`,修改容器白名单: + +```yaml +scrape_configs: + - docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + # 白名单:只收集以下前缀的容器(多个用 | 分隔) + - source_labels: ['__meta_docker_container_name'] + regex: '/(light-sentry-.+|myapp-.+|nginx)' + action: keep +``` + +### 6.2 业务机 Promtail(一键部署) + +通过项目管理页面的「一键部署」功能,自动将 Promtail Agent 部署到业务 ECS 服务器。 + +**部署步骤:** + +1. 打开 `https://log.starseekai.cn/manage/` +2. 编辑项目,填写「关联容器名称」或「容器过滤正则」 +3. 点击「一键部署」,填写业务机 SSH 信息 +4. 系统自动完成:SSH 连接 → 生成配置 → 部署 Promtail → 重启 Dozzle Agent + +### 6.3 Dozzle 容器过滤 + +Dozzle 默认只显示 `light-sentry-*` 前缀的容器。修改 `.env` 可调整: + +```bash +# 格式:key=value,支持正则,多个用逗号分隔 +DOZZLE_FILTER=name=light-sentry-.+|myapp-.+|nginx +``` + +### 6.4 Grafana 中查询容器日志 + +Promtail 采集的日志带以下标签: + +| 标签 | 说明 | +|------|------| +| `host` | 主机名 | +| `source` | `container` 标识为容器日志 | +| `container_name` | Docker 容器名称 | +| `container_image` | Docker 镜像名称 | +| `composed_project` | Docker Compose 项目名 | +| `docker_service` | Docker Compose 服务名 | + +**查询示例:** + +```logql +# 按容器名查询 +{container_name="myapp-web"} + +# 按镜像名查询 +{container_image=~"nginx.*"} + +# 组合 Sentry 项目 + 容器日志 +{container_name=~"myapp-.*", host="prod-ecs"} + +# 查看该容器最近 1 小时的日志 +{container_name="myapp-web"} |= "ERROR" | __error__!="JSONErr" +``` + +--- + +## 7. 项目与容器关联 + +### 7.1 关联步骤 + +1. 打开项目管理页面 `https://log.starseekai.cn/manage/` +2. 编辑项目,填写容器信息: + - **关联容器名称**:精确匹配容器名,多个用逗号分隔 + - **容器过滤正则**(可选):支持正则表达式,优先级更高 + +| 关联方式 | 示例 | 说明 | +|---------|------|------| +| 单容器 | `myapp-web` | 只采集指定容器 | +| 多容器 | `myapp-web, myapp-api` | 逗号分隔 | +| 正则匹配 | `myapp-.*` | 匹配所有 myapp- 开头的容器 | + +### 7.2 接入声明 + +项目管理页面提供一键复制功能,包含: + +- SDK 接入代码片段 +- 完整的 DSN 地址 +- Grafana 仪表盘跳转链接 +- Dozzle 容器日志跳转链接 + +### 7.3 项目管理 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| `GET` | `/api/projects/` | 列出所有项目 | +| `GET` | `/api/projects/:id` | 获取单个项目详情 | +| `POST` | `/api/projects/` | 创建新项目 | +| `PUT` | `/api/projects/:id` | 更新项目信息 | +| `DELETE` | `/api/projects/:id` | 删除项目 | + +**创建项目示例:** + +```bash +curl -X POST https://log.starseekai.cn/api/projects/ \ + -H "Content-Type: application/json" \ + -d '{ + "id": "order-service", + "name": "订单服务", + "platform": "node", + "environment": "production", + "description": "处理订单创建和支付", + "containerName": "order-service-web, order-service-worker" + }' +``` + +--- + +## 8. 一键部署业务机 + +通过项目管理页面的「一键部署」功能,将 Promtail Agent 和 Dozzle Agent 自动部署到业务 ECS 服务器。 + +### 8.1 部署流程 + +``` +1. 用户在页面填写业务机 SSH 信息(IP、用户名、密码) +2. 后端启动异步部署任务 +3. SSH 连接业务机 +4. 创建部署目录、生成 Promtail/Dozzle 配置 +5. SCP 上传配置文件和 docker-compose.yml +6. 启动容器 +7. 同步 Agent 信息到中心 Dozzle +8. 实时 SSE 推送部署日志到前端 +``` + +### 8.2 部署配置说明 + +**Loki Push 地址**:由项目管理页面的「中心 Loki 地址」配置,默认为 `https://log.starseekai.cn/loki/loki/api/v1/push` + +**部署目录**:默认 `/opt/light-sentry-agent`,可在部署时自定义 + +**容器过滤**:根据项目关联的容器名自动生成 Promtail 过滤正则 + +### 8.3 部署目录结构 + +``` +/opt/light-sentry-agent/ +├── docker-compose.yml # Dozzle Agent +├── promtail/ +│ └── promtail-config.yml # Promtail 配置 +└── .env # 环境变量(DOZZLE_REMOTE_AGENT) +``` + +### 8.4 查看部署历史 + +部署历史可在项目管理页面查看,包含: + +- 部署时间、目标主机 +- 部署状态(成功/失败) +- 实时部署日志 +- 容器过滤配置 + +--- + +## 9. 常见问题 + +### Q: SDK 上报后看不到日志 + +**排查步骤:** + +1. 检查浏览器控制台是否有 Sentry 报错 +2. 确认 DSN 是否正确:`https://{publicKey}@log.starseekai.cn/{projectId}` +3. 确认 `projectId` 是否是纯数字(如 `1001`) +4. 在 Grafana Explore 中直接查询原始日志: + +```logql +{platform="javascript"} +``` + +### Q: 容器日志没有采集到 + +**排查步骤:** + +1. 确认 Promtail 容器是否在运行:`docker ps | grep promtail` +2. 检查 Promtail 日志:`docker logs light-sentry-promtail` +3. 确认容器名是否在白名单中 +4. 检查 Loki 是否收到数据:`curl http://localhost:3100/loki/api/v1/label/container_name/values` + +### Q: 一键部署失败 + +**常见原因:** + +1. SSH 密码错误或 IP 无法连接 +2. 目标目录权限不足(确保 SSH 用户有 `/opt` 写权限) +3. Docker 未安装或未启动 + +**解决方案:** + +- 手动检查 SSH 连接:`ssh user@host` +- 检查目标目录权限:`ls -la /opt/` +- 查看部署日志中的详细错误信息 + +### Q: 生产环境建议采样率是多少? + +| 环境 | tracesSampleRate | 说明 | +|------|-----------------|------| +| 开发/测试 | `1.0` | 全量采集,方便调试 | +| 预发/灰度 | `0.5` | 采集一半 | +| 生产 | `0.05 ~ 0.2` | 5%~20%,平衡性能和数据量 | + +### Q: 如何在 Grafana 中切换项目查看? + +在 Grafana 仪表盘顶部有 **项目** 下拉框,选择后可按项目过滤日志。仪表盘地址格式: + +``` +https://log.starseekai.cn/grafana/d/light-sentry-logs?var-project=1 +``` + +其中 `var-project` 参数值为项目 ID。 + +### Q: 日志量会不会太大? + +假设: +- 每小时 10000 次 API 请求 +- tracesSampleRate = 0.1(10%) +- 每条 trace 上报 3~5 条日志 +- 每条日志约 2KB + +``` +月流量 ≈ 10000 × 0.1 × 4 × 2KB × 24h × 30d ≈ 57 MB +``` + +非常轻量,2核2G 完全没压力。 + +### Q: 如何添加自定义标签? + +```javascript +// 设置全局标签(所有事件都会带) +Sentry.setTag('server', 'production'); +Sentry.setTag('region', 'cn-hangzhou'); + +// 设置用户级别标签 +Sentry.setContext('order', { + orderId: 'ORD_12345', + amount: 99.9, + currency: 'CNY', +}); +``` diff --git a/src/core/Client.ts b/src/core/Client.ts index 28e81ed..0577b39 100644 --- a/src/core/Client.ts +++ b/src/core/Client.ts @@ -1,7 +1,8 @@ -import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb, ErrorEvent } from '../types'; +import type { LightConfig, LightClient, LightPlugin, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb, ErrorEvent } from '../types'; import { parseDSN } from '../utils/dsn'; import { now } from '../utils/helper'; import { computeFingerprint } from '../utils/hash'; +import { parseStackTrace } from '../utils/stacktrace'; import { ConfigManager } from './ConfigManager'; import { EventBus } from './EventBus'; import { EventQueue } from './EventQueue'; @@ -9,7 +10,6 @@ import { Reporter } from './Reporter'; import { PluginManager } from './PluginManager'; class Client implements LightClient { - config: LightConfig; dsn: DSNInfo; private configManager: ConfigManager; @@ -18,9 +18,16 @@ class Client implements LightClient { private reporter: Reporter; private pluginManager: PluginManager; private breadcrumbs: Breadcrumb[] = []; - private maxBreadcrumbs = 20; private enabled: boolean = true; + private get maxBreadcrumbs(): number { + return this.configManager.get('maxBreadcrumbs') ?? 20; + } + + get config(): LightConfig { + return this.configManager.getAll(); + } + constructor(config: LightConfig) { if (!config.dsn) { throw new Error('DSN is required'); @@ -28,23 +35,22 @@ class Client implements LightClient { this.dsn = parseDSN(config.dsn); this.configManager = new ConfigManager(config); - this.config = this.configManager.getAll(); this.eventBus = new EventBus(); this.reporter = new Reporter( this.dsn, - config.maxRetries ?? 3, - config.retryDelay ?? 1000 + this.configManager.get('maxRetries') ?? 3, + this.configManager.get('retryDelay') ?? 1000 ); this.pluginManager = new PluginManager(this); this.queue = new EventQueue( - config.maxQueueSize ?? 100, - config.flushInterval ?? 5000, + this.configManager.get('maxQueueSize') ?? 100, + this.configManager.get('flushInterval') ?? 5000, this.eventBus, async (events) => this.flushEvents(events), (events) => this.syncFlushEvents(events) ); - this.enabled = config.enabled !== false; + this.enabled = this.configManager.get('enabled') !== false; } init(): void { @@ -65,28 +71,23 @@ class Client implements LightClient { if (processedEvents.length === 0) return; - // 批次内优化:只有第一个事件带完整环境信息 - // 后续事件只带 context_id,服务端根据 ID 关联 let isFirstInBatch = true; const finalEvents: SentryEvent[] = []; for (const event of processedEvents) { - // 标记是否为批次首个事件 const withConfig = this.configManager.applyToEvent(event, isFirstInBatch); - isFirstInBatch = false; // 后续事件不再带完整 contexts + isFirstInBatch = false; - const finalEvent = this.config.get('beforeSend') - ? this.config.get('beforeSend')!(withConfig) - : withConfig; - if (finalEvent) { - finalEvents.push(finalEvent); - } + const beforeSend = this.configManager.get('beforeSend'); + const afterBeforeSend = beforeSend ? beforeSend(withConfig) : withConfig; + if (!afterBeforeSend) continue; + + finalEvents.push(afterBeforeSend); } if (finalEvents.length === 0) return; await this.reporter.report(finalEvents); - // 批次结束后标记,后续批次不再带完整 contexts this.configManager.markContextReported(); for (const event of finalEvents) { @@ -106,7 +107,6 @@ class Client implements LightClient { if (processedEvents.length === 0) return; - // 批次内优化:只有第一个事件带完整环境信息 let isFirstInBatch = true; const finalEvents: SentryEvent[] = []; @@ -114,12 +114,11 @@ class Client implements LightClient { const withConfig = this.configManager.applyToEvent(event, isFirstInBatch); isFirstInBatch = false; - const finalEvent = this.config.get('beforeSend') - ? this.config.get('beforeSend')!(withConfig) - : withConfig; - if (finalEvent) { - finalEvents.push(finalEvent); - } + const beforeSend = this.configManager.get('beforeSend'); + const afterBeforeSend = beforeSend ? beforeSend(withConfig) : withConfig; + if (!afterBeforeSend) continue; + + finalEvents.push(afterBeforeSend); } if (finalEvents.length === 0) return; @@ -141,7 +140,7 @@ class Client implements LightClient { } captureException(error: Error | unknown): void { - if (!this.enabled || !this.configManager.shouldSample()) return; + if (!this.enabled) return; const errorEvent = this.buildErrorEvent(error); if (this.configManager.isIgnoredError(errorEvent.message)) return; @@ -151,7 +150,7 @@ class Client implements LightClient { } captureMessage(message: string, level: EventLevel = 'info'): void { - if (!this.enabled || !this.configManager.shouldSample()) return; + if (!this.enabled) return; const event: ErrorEvent = { type: 'error', @@ -167,11 +166,12 @@ class Client implements LightClient { } captureEvent(event: Partial & { type: string }): void { - if (!this.enabled || !this.configManager.shouldSample()) return; + if (!this.enabled) return; const fullEvent = { timestamp: now(), level: 'info' as EventLevel, + breadcrumbs: [...this.breadcrumbs], ...event, } as SentryEvent; @@ -182,7 +182,7 @@ class Client implements LightClient { const timestamp = now(); if (error instanceof Error) { - const frames = this.parseStackTrace(error.stack); + const frames = parseStackTrace(error.stack, { maxFrames: 5 }); const fingerprint = computeFingerprint(error.name, error.message, frames); return { @@ -194,7 +194,7 @@ class Client implements LightClient { type: error.name, value: error.message, stacktrace: { - frames: frames.slice(0, 5), + frames, }, }, fingerprint, @@ -210,40 +210,6 @@ class Client implements LightClient { }; } - private parseStackTrace(stack?: string): { filename: string; function?: string; lineno?: number; colno?: number; in_app?: boolean }[] { - if (!stack) return []; - - const frames: { filename: string; function?: string; lineno?: number; colno?: number; in_app?: boolean }[] = []; - const lines = stack.split('\n'); - - for (const line of lines) { - const match = line.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/); - if (match) { - const [, fn, filename, lineno, colno] = match; - frames.push({ - filename, - function: fn, - lineno: parseInt(lineno, 10), - colno: parseInt(colno, 10), - in_app: !filename.includes('node_modules'), - }); - } else { - const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/); - if (urlMatch) { - const [, filename, lineno, colno] = urlMatch; - frames.push({ - filename, - lineno: parseInt(lineno, 10), - colno: parseInt(colno, 10), - in_app: !filename.includes('node_modules'), - }); - } - } - } - - return frames.reverse(); - } - setUser(user: UserInfo | null): void { this.configManager.setUser(user); } @@ -284,10 +250,8 @@ class Client implements LightClient { this.enabled = true; } - use(plugin: unknown): void { - if (plugin && typeof plugin === 'object' && 'name' in plugin && 'setup' in plugin) { - this.pluginManager.add(plugin as never); - } + use(plugin: LightPlugin): void { + this.pluginManager.add(plugin); } destroy(): void { diff --git a/src/core/ConfigManager.ts b/src/core/ConfigManager.ts index fd8a9c8..ca76418 100644 --- a/src/core/ConfigManager.ts +++ b/src/core/ConfigManager.ts @@ -1,5 +1,6 @@ -import type { LightConfig, SentryEvent, UserInfo } from '../types'; +import type { LightConfig, SentryEvent, UserInfo, ContextLevelMap, ErrorEvent, StackFrame, Breadcrumb, EventLevel } from '../types'; import { getContexts, getContextId, getPageUrl, getReferrer } from '../utils/env'; +import { matchPatterns } from '../utils/helper'; const DEFAULT_CONFIG: Partial = { enabled: true, @@ -57,7 +58,7 @@ export class ConfigManager { if (!this.config.tags) { this.config.tags = {}; } - this.config.tags[key] = value; + (this.config.tags as Record)[key] = value; } setExtra(key: string, value: unknown): void { @@ -67,21 +68,8 @@ export class ConfigManager { (this.config.extra as Record)[key] = value; } - shouldSample(): boolean { - const rate = this.config.sampleRate ?? 1; - if (rate >= 1) return true; - if (rate <= 0) return false; - return Math.random() < rate; - } - isIgnoredError(message: string): boolean { - const patterns = this.config.ignoreErrors || []; - return patterns.some(pattern => { - if (typeof pattern === 'string') { - return message.includes(pattern); - } - return pattern.test(message); - }); + return matchPatterns(message, this.config.ignoreErrors || []); } /** @@ -101,49 +89,81 @@ export class ConfigManager { * @param includeFullContext 是否包含完整环境信息(批次内只有首个事件需要) */ applyToEvent(event: SentryEvent, includeFullContext: boolean = true): SentryEvent { - if (this.config.release) { - event.release = this.config.release; - } - if (this.config.environment) { - event.environment = this.config.environment; - } - if (this.config.user) { - event.user = this.config.user; - } - if (this.config.tags) { - event.tags = { ...this.config.tags, ...(event.tags || {}) }; + // 使用 Record 来避免联合类型的问题 + const enriched: Record = { ...event }; + + // 处理 tags + if (event.tags) { + enriched.tags = { ...event.tags }; } - if (!event.request) { - event.request = { + // 处理 request + if (event.request) { + enriched.request = { ...event.request }; + } + + // 处理 exception(仅 error 事件有) + if ('exception' in event && event.exception) { + const exc = event.exception; + enriched.exception = { + ...exc, + stacktrace: exc.stacktrace ? { + ...exc.stacktrace, + frames: exc.stacktrace.frames ? exc.stacktrace.frames.map((f: StackFrame) => ({ ...f })) : undefined, + } : undefined, + }; + } + + // 处理 breadcrumbs + if (event.breadcrumbs) { + enriched.breadcrumbs = event.breadcrumbs.map((b: Breadcrumb) => ({ ...b })); + } + + // 处理 user + if (event.user) { + enriched.user = { ...event.user }; + } + + // 处理 contexts + if (event.contexts) { + enriched.contexts = { ...event.contexts }; + } + + if (this.config.release) { + enriched.release = this.config.release; + } + if (this.config.environment) { + enriched.environment = this.config.environment; + } + if (this.config.user) { + enriched.user = this.config.user; + } + if (this.config.tags) { + enriched.tags = { ...this.config.tags, ...(event.tags as Record || {}) }; + } + + if (!enriched.request) { + enriched.request = { url: getPageUrl(), referrer: getReferrer(), }; - } else if (!event.request.url) { - event.request.url = getPageUrl(); + } else if (!(enriched.request as Record).url) { + (enriched.request as Record).url = getPageUrl(); } - // context_id 总是带上,用于服务端关联环境信息 if (!this.contextId) { this.contextId = getContextId(); } - event.context_id = this.contextId; + enriched.context_id = this.contextId; - // 应用上下文分层策略:按事件级别裁剪堆栈和 breadcrumbs - this.applyContextLevel(event); + const result = this.applyContextLevel(enriched); - // 只有需要完整环境信息时才附加 contexts - // 批次内:首个事件带完整 contexts,后续事件只带 context_id - // 跨批次:首次上报带完整 contexts,后续批次只带 context_id if (includeFullContext && !this.contextReported) { if (!this.contexts) { this.contexts = getContexts(); } - // 完整环境信息(已编码:browser.name -> c, os.name -> m 等) - event.contexts = this.contexts; - // 额外附加 env 字段,方便服务端识别编码格式 - // env 字段使用短字段名:b=浏览器, bv=浏览器版本, os=系统, osv=系统版本 - (event as Record).env = { + (result as Record).contexts = this.contexts; + (result as Record).env = { b: this.contexts.browser?.name, bv: this.contexts.browser?.version, os: this.contexts.os?.name, @@ -151,33 +171,55 @@ export class ConfigManager { }; } - return event; + return result as unknown as SentryEvent; } /** * 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs + * 注意:此方法不修改原始 event 对象,返回裁剪后的副本 */ - private applyContextLevel(event: SentryEvent): void { - const contextLevel = this.config.contextLevel as Record; - if (!contextLevel) return; + private applyContextLevel(event: Record): Record { + const contextLevel = this.config.contextLevel as ContextLevelMap | undefined; + if (!contextLevel) return event; - const level = event.level || 'error'; + const level = (event.level || 'error') as EventLevel; const levelConfig = contextLevel[level] || contextLevel['error']; - // 裁剪堆栈帧 - if (event.exception?.stacktrace?.frames && levelConfig.maxStackFrames > 0) { - event.exception.stacktrace.frames = event.exception.stacktrace.frames.slice(0, levelConfig.maxStackFrames); - } else if (levelConfig.maxStackFrames === 0 && event.exception?.stacktrace?.frames) { - // info/debug 级别不需要堆栈 - delete event.exception.stacktrace; + // 创建浅拷贝用于裁剪 + const enriched: Record = { ...event }; + + // 裁剪堆栈帧(仅 error 事件有 exception) + if ('exception' in event && event.exception && typeof event.exception === 'object') { + const exc = event.exception as Record; + if (exc.stacktrace && typeof exc.stacktrace === 'object') { + const st = exc.stacktrace as Record; + if (Array.isArray(st.frames)) { + if (levelConfig.maxStackFrames === 0) { + // info/debug 级别不需要堆栈 + delete enriched.exception; + } else { + enriched.exception = { + ...exc, + stacktrace: { + ...st, + frames: (st.frames as StackFrame[]).slice(0, levelConfig.maxStackFrames), + }, + }; + } + } + } } // 裁剪 breadcrumbs - if (event.breadcrumbs && levelConfig.maxBreadcrumbs > 0) { - event.breadcrumbs = event.breadcrumbs.slice(-levelConfig.maxBreadcrumbs); - } else if (levelConfig.maxBreadcrumbs === 0) { - // info/debug 级别不需要 breadcrumbs - delete event.breadcrumbs; + if (event.breadcrumbs && Array.isArray(event.breadcrumbs)) { + if (levelConfig.maxBreadcrumbs === 0) { + // info/debug 级别不需要 breadcrumbs + delete enriched.breadcrumbs; + } else { + enriched.breadcrumbs = (event.breadcrumbs as Breadcrumb[]).slice(-levelConfig.maxBreadcrumbs); + } } + + return enriched; } } diff --git a/src/core/EventQueue.ts b/src/core/EventQueue.ts index 5d767f9..d12f169 100644 --- a/src/core/EventQueue.ts +++ b/src/core/EventQueue.ts @@ -17,6 +17,10 @@ export class EventQueue { private flushCallback: (events: SentryEvent[]) => Promise; private syncFlushCallback: (events: SentryEvent[]) => void; private lastFlushTime: number = 0; + private isFlushing: boolean = false; + private onVisibilityChange?: () => void; + private onBeforeUnload?: () => void; + private onPagehide?: () => void; private dedupeMap: Map = new Map(); private pendingCounts: Map = new Map(); @@ -53,26 +57,28 @@ export class EventQueue { } private setupVisibilityListener(): void { + this.onVisibilityChange = () => { + if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) { + this.flushSync(); + } + }; if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) { - this.flushSync(); - } - }); + document.addEventListener('visibilitychange', this.onVisibilityChange); } + this.onBeforeUnload = () => { + if (this.queue.length > 0 || this.pendingCounts.size > 0) { + this.flushSync(); + } + }; + this.onPagehide = () => { + if (this.queue.length > 0 || this.pendingCounts.size > 0) { + this.flushSync(); + } + }; if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (this.queue.length > 0 || this.pendingCounts.size > 0) { - this.flushSync(); - } - }); - - window.addEventListener('pagehide', () => { - if (this.queue.length > 0 || this.pendingCounts.size > 0) { - this.flushSync(); - } - }); + window.addEventListener('beforeunload', this.onBeforeUnload); + window.addEventListener('pagehide', this.onPagehide); } } @@ -91,6 +97,11 @@ export class EventQueue { window.shift(); } + if (window.length === 0) { + delete this.errorRateWindow[fingerprint]; + return false; + } + if (window.length > 10) { if (!this.paused) { console.warn('[LightSDK] Infinite loop detected, pausing SDK for 60s', { fingerprint, count: window.length }); @@ -115,6 +126,8 @@ export class EventQueue { enqueue(event: SentryEvent): void { if (this.paused) return; + this.cleanupExpiredDedupeEntries(); + const fingerprint = this.getDedupeKey(event); if (fingerprint) { @@ -148,10 +161,6 @@ export class EventQueue { } } - if (this.queue.length >= this.maxSize) { - this.flush(); - } - this.queue.push(event); this.eventBus.emit('event', event); @@ -160,11 +169,24 @@ export class EventQueue { } } + private cleanupExpiredDedupeEntries(): void { + // 每 100 条或每 10 次清理一次,避免过于频繁 + if (this.dedupeMap.size < 100 && Math.random() > 0.1) return; + + const currentTime = now(); + const expiryTime = 60000; + + for (const [key, entry] of this.dedupeMap) { + if (currentTime - entry.lastTime > expiryTime) { + this.dedupeMap.delete(key); + } + } + } + private buildCountEvents(): SentryEvent[] { const countEvents: SentryEvent[] = []; for (const [fingerprint, data] of this.pendingCounts.entries()) { - // 构建聚合计数事件 const event: CountEvent = { type: 'count', fingerprint, @@ -177,8 +199,6 @@ export class EventQueue { countEvents.push(event); } - // 清空已上报的聚合计数 - this.pendingCounts.clear(); return countEvents; } @@ -192,6 +212,9 @@ export class EventQueue { async flush(): Promise { if (this.queue.length === 0 && this.pendingCounts.size === 0) return; + if (this.isFlushing) return; + + this.isFlushing = true; const events = this.queue.splice(0, this.queue.length); const countEvents = this.buildCountEvents(); @@ -202,10 +225,13 @@ export class EventQueue { try { await this.flushCallback(allEvents); + this.pendingCounts.clear(); this.eventBus.emit('reported', allEvents); } catch (e) { events.forEach(evt => this.queue.unshift(evt)); throw e; + } finally { + this.isFlushing = false; } } @@ -213,6 +239,7 @@ export class EventQueue { if (this.queue.length === 0 && this.pendingCounts.size === 0) return; const events = this.queue.splice(0, this.queue.length); + const pendingCountsSnapshot = new Map(this.pendingCounts); const countEvents = this.buildCountEvents(); const allEvents = [...events, ...countEvents]; @@ -221,9 +248,16 @@ export class EventQueue { try { this.syncFlushCallback(allEvents); + this.pendingCounts.clear(); this.eventBus.emit('reported', allEvents); } catch (e) { console.error('[LightSDK] Sync flush failed', e); + events.forEach(evt => this.queue.unshift(evt)); + for (const [key, value] of pendingCountsSnapshot) { + if (!this.pendingCounts.has(key)) { + this.pendingCounts.set(key, value); + } + } } } @@ -240,6 +274,17 @@ export class EventQueue { clearTimeout(this.pauseTimer); this.pauseTimer = null; } + if (typeof document !== 'undefined' && this.onVisibilityChange) { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } + if (typeof window !== 'undefined') { + if (this.onBeforeUnload) { + window.removeEventListener('beforeunload', this.onBeforeUnload); + } + if (this.onPagehide) { + window.removeEventListener('pagehide', this.onPagehide); + } + } this.dedupeMap.clear(); this.pendingCounts.clear(); this.errorRateWindow = {}; diff --git a/src/core/Reporter.ts b/src/core/Reporter.ts index 711311f..d99e835 100644 --- a/src/core/Reporter.ts +++ b/src/core/Reporter.ts @@ -1,6 +1,6 @@ import type { DSNInfo, SentryEvent, ErrorEvent } from '../types'; import { getEnvelopeUrl } from '../utils/dsn'; -import { now } from '../utils/helper'; +import { now, generateEventId } from '../utils/helper'; import { encodeFields, FIELD_ENCODE_MAP } from '../utils/field-encoder'; export class Reporter { @@ -34,7 +34,8 @@ export class Reporter { if (retries > this.maxRetries) { throw e; } - await this.delay(this.retryDelay * Math.pow(2, retries - 1)); + const backoff = Math.min(this.retryDelay * Math.pow(2, retries - 1), 5000); + await this.delay(backoff); } } } @@ -68,7 +69,7 @@ export class Reporter { // 构建 header(包含公共元数据) const headerObj: Record = { - event_id: this.generateEventId(), + event_id: generateEventId(), sent_at: new Date().toISOString(), meta: sharedMeta, }; @@ -87,16 +88,16 @@ export class Reporter { for (const event of eventsWithRelativeTs) { // 移除已共享的字段,减少重复 - let strippedEvent = this.stripSharedFields(event, sharedMeta); + let strippedEvent: unknown = this.stripSharedFields(event as unknown as SentryEvent, sharedMeta); // 短字段编码(如果启用) if (this.useShortFields) { - strippedEvent = encodeFields(strippedEvent as Record) as SentryEvent; + strippedEvent = encodeFields(strippedEvent as Record); } const itemPayload = JSON.stringify(strippedEvent); const itemHeader = JSON.stringify({ - type: this.getEnvelopeType(event), + type: this.getEnvelopeType(event as unknown as SentryEvent), length: itemPayload.length, }); items.push(itemHeader, itemPayload); @@ -114,10 +115,10 @@ export class Reporter { * * 这样可以将时间戳从 13 位数字减少到 2-4 位数字 */ - private applyRelativeTimestamps(events: SentryEvent[], headerObj: Record): SentryEvent[] { + private applyRelativeTimestamps(events: SentryEvent[], headerObj: Record): (SentryEvent | Record)[] { if (events.length <= 1) return events; - const result: SentryEvent[] = []; + const result: (SentryEvent | Record)[] = []; const baseTimestamp = events[0].timestamp ? new Date(events[0].timestamp).getTime() : Date.now(); // 在 header 中标记使用相对时间戳 @@ -125,11 +126,11 @@ export class Reporter { headerObj._bt = baseTimestamp; // base timestamp for (let i = 0; i < events.length; i++) { - const event = { ...events[i] } as Record; + const event: Record = { ...events[i] }; if (i === 0) { // 第一个事件保持完整时间戳 - result.push(event as SentryEvent); + result.push(event as unknown as SentryEvent); } else { // 后续事件用差值 const eventTs = event.timestamp ? new Date(event.timestamp as string | number).getTime() : Date.now(); @@ -146,7 +147,7 @@ export class Reporter { event._dsts = startDelta; // delta start timestamp } - result.push(event as SentryEvent); + result.push(event); } } @@ -189,7 +190,7 @@ export class Reporter { } // 提取 env(编码后的环境信息) - const firstEnv = (firstEvent as Record).env; + const firstEnv = (firstEvent as unknown as Record).env; if (firstEnv) { meta.env = firstEnv; } @@ -200,8 +201,8 @@ export class Reporter { /** * 移除已共享的字段,减少重复 */ - private stripSharedFields(event: SentryEvent, meta: Record): SentryEvent { - const stripped = { ...event }; + private stripSharedFields(event: SentryEvent, meta: Record): Record { + const stripped: Record = { ...event }; // 移除已共享的字段 if (meta.release && stripped.release === meta.release) { @@ -214,7 +215,7 @@ export class Reporter { delete stripped.user; } if (meta.env) { - delete (stripped as Record).env; + delete stripped.env; } return stripped; @@ -231,12 +232,6 @@ export class Reporter { } } - private generateEventId(): string { - return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/[x]/g, () => { - return ((Math.random() * 16) | 0).toString(16); - }); - } - private async send(body: string, isSync: boolean = false): Promise { const url = getEnvelopeUrl(this.dsn); @@ -252,6 +247,8 @@ export class Reporter { if (typeof fetch === 'function') { try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); const response = await fetch(url, { method: 'POST', body, @@ -259,7 +256,9 @@ export class Reporter { 'Content-Type': 'application/x-sentry-envelope', }, keepalive: true, + signal: controller.signal, }); + clearTimeout(timeoutId); if (response.ok) return; const err = new Error(`HTTP ${response.status}`) as Error & { status: number }; err.status = response.status; @@ -274,6 +273,7 @@ export class Reporter { const xhr = new XMLHttpRequest(); xhr.open('POST', url, !isSync); xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope'); + xhr.timeout = 10000; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); @@ -284,6 +284,7 @@ export class Reporter { } }; xhr.onerror = () => reject(new Error('Network error')); + xhr.ontimeout = () => reject(new Error('Request timeout')); xhr.send(body); }); } @@ -323,7 +324,8 @@ export class Reporter { const url = getEnvelopeUrl(this.dsn); const img = new Image(); const encoded = encodeURIComponent(btoa(body)); - img.src = url + '&sentry_data=' + encoded.substring(0, 2000); + const separator = url.includes('?') ? '&' : '?'; + img.src = url + separator + 'sentry_data=' + encoded.substring(0, 2000); return true; } catch { return false; diff --git a/src/index.ts b/src/index.ts index ab42bf5..34cb28c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import PerformancePlugin from './plugins/PerformancePlugin'; import NetworkPlugin from './plugins/NetworkPlugin'; import BehaviorPlugin from './plugins/BehaviorPlugin'; import OfflinePlugin from './plugins/OfflinePlugin'; +import SamplingPlugin from './plugins/SamplingPlugin'; import type { LightConfig, LightClient, SentryEvent, EventLevel, UserInfo, Breadcrumb, LightPlugin, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, DSNInfo } from './types'; let globalClient: LightClient | null = null; @@ -12,8 +13,9 @@ function init(config: LightConfig): LightClient { const client = new Client(config); const defaultPlugins: LightPlugin[] = [ - new ErrorPlugin(), new OfflinePlugin(), + new SamplingPlugin(), + new ErrorPlugin(), ]; const pluginNames = config.plugins?.filter(p => typeof p === 'string') as string[] || []; @@ -29,18 +31,18 @@ function init(config: LightConfig): LightClient { } for (const plugin of defaultPlugins) { - (client as unknown as { use: (p: LightPlugin) => void }).use(plugin); + client.use(plugin); } if (config.plugins) { for (const plugin of config.plugins) { if (typeof plugin === 'object' && plugin !== null && 'name' in plugin && 'setup' in plugin) { - (client as unknown as { use: (p: LightPlugin) => void }).use(plugin as LightPlugin); + client.use(plugin as LightPlugin); } } } - (client as unknown as { init: () => void }).init(); + client.init(); globalClient = client; return client; @@ -114,6 +116,7 @@ export { NetworkPlugin, BehaviorPlugin, OfflinePlugin, + SamplingPlugin, }; export type { diff --git a/src/plugins/BehaviorPlugin.ts b/src/plugins/BehaviorPlugin.ts index 7251be0..86e6696 100644 --- a/src/plugins/BehaviorPlugin.ts +++ b/src/plugins/BehaviorPlugin.ts @@ -1,6 +1,6 @@ import type { LightPlugin, LightClient, BehaviorEvent, LightConfig } from '../types'; import { now, isObject } from '../utils/helper'; -import { getPageUrl } from '../utils/env'; +import { getPageUrl, sanitizeUrl } from '../utils/env'; interface BehaviorPluginConfig { capturePV?: boolean; @@ -26,7 +26,7 @@ class BehaviorPlugin implements LightPlugin { captureScroll: true, clickThrottle: 300, scrollThrottle: 1000, - sampleRate: 0.1, + sampleRate: 0.01, }; private lastClickTime: number = 0; @@ -35,14 +35,20 @@ class BehaviorPlugin implements LightPlugin { private pageEnterTime: number = 0; private scrollReported: boolean = false; + private originalPushState?: typeof history.pushState; + private originalReplaceState?: typeof history.replaceState; + private onScroll?: () => void; + private onClick?: (e: Event) => void; + private onVisibilityChange?: () => void; + private onBeforeUnload?: () => void; + private onPagehide?: () => void; + private onPopState?: () => void; + private onHashchange?: () => void; + setup(client: LightClient): void { this.client = client; this.loadConfig(); - if (!this.shouldSample()) { - return; - } - if (this.config.capturePV) { this.trackPV(); } @@ -68,7 +74,7 @@ class BehaviorPlugin implements LightPlugin { } private shouldSample(): boolean { - const rate = this.config.sampleRate ?? 0.1; + const rate = this.config.sampleRate ?? 0.01; if (rate >= 1) return true; if (rate <= 0) return false; return Math.random() < rate; @@ -90,7 +96,7 @@ class BehaviorPlugin implements LightPlugin { if (typeof window === 'undefined') return; const url = getPageUrl(); - const referrer = document.referrer; + const referrer = sanitizeUrl(document.referrer || ''); this.reportBehavior({ sub_type: 'pv', @@ -111,7 +117,7 @@ class BehaviorPlugin implements LightPlugin { private trackClick(): void { if (typeof document === 'undefined') return; - document.addEventListener('click', (e) => { + this.onClick = (e: Event) => { const currentTime = now(); const throttle = this.config.clickThrottle ?? 300; if (currentTime - this.lastClickTime < throttle) { @@ -142,11 +148,13 @@ class BehaviorPlugin implements LightPlugin { selector, text: text || undefined, tag: target.tagName?.toLowerCase(), - x: e.clientX, - y: e.clientY, + x: (e as MouseEvent).clientX, + y: (e as MouseEvent).clientY, }, }); - }, true); + }; + + document.addEventListener('click', this.onClick, true); } private trackRoute(): void { @@ -177,28 +185,27 @@ class BehaviorPlugin implements LightPlugin { } }; - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; + this.originalPushState = history.pushState; + this.originalReplaceState = history.replaceState; + const self = this; history.pushState = function () { - const result = originalPushState.apply(this, arguments as unknown as Parameters); + const result = self.originalPushState!.apply(this, arguments as unknown as Parameters); setTimeout(checkRoute, 0); return result; }; history.replaceState = function () { - const result = originalReplaceState.apply(this, arguments as unknown as Parameters); + const result = self.originalReplaceState!.apply(this, arguments as unknown as Parameters); setTimeout(checkRoute, 0); return result; }; - window.addEventListener('popstate', () => { - setTimeout(checkRoute, 0); - }); + this.onPopState = () => { setTimeout(checkRoute, 0); }; + this.onHashchange = () => { setTimeout(checkRoute, 0); }; - window.addEventListener('hashchange', () => { - setTimeout(checkRoute, 0); - }); + window.addEventListener('popstate', this.onPopState); + window.addEventListener('hashchange', this.onHashchange); } private trackPageDuration(): void { @@ -224,22 +231,26 @@ class BehaviorPlugin implements LightPlugin { } }; - document.addEventListener('visibilitychange', () => { + this.onVisibilityChange = () => { if (document.hidden) { sendDuration(); } else { this.pageEnterTime = now(); } - }); + }; - window.addEventListener('beforeunload', sendDuration); - window.addEventListener('pagehide', sendDuration); + this.onBeforeUnload = sendDuration; + this.onPagehide = sendDuration; + + document.addEventListener('visibilitychange', this.onVisibilityChange); + window.addEventListener('beforeunload', this.onBeforeUnload); + window.addEventListener('pagehide', this.onPagehide); } private trackScroll(): void { if (typeof window === 'undefined') return; - const onScroll = () => { + this.onScroll = () => { const currentTime = now(); const throttle = this.config.scrollThrottle ?? 1000; if (currentTime - this.lastScrollTime < throttle) { @@ -253,7 +264,7 @@ class BehaviorPlugin implements LightPlugin { } }; - window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('scroll', this.onScroll, { passive: true }); } private reportScroll(): void { @@ -270,6 +281,8 @@ class BehaviorPlugin implements LightPlugin { } private reportBehavior(data: Omit): void { + if (!this.shouldSample()) return; + const event: BehaviorEvent = { type: 'behavior', level: 'info', @@ -284,7 +297,39 @@ class BehaviorPlugin implements LightPlugin { this.client.captureEvent(event); } - destroy(): void {} + destroy(): void { + if (typeof window !== 'undefined') { + if (this.originalPushState) { + history.pushState = this.originalPushState; + } + if (this.originalReplaceState) { + history.replaceState = this.originalReplaceState; + } + if (this.onPopState) { + window.removeEventListener('popstate', this.onPopState); + } + if (this.onHashchange) { + window.removeEventListener('hashchange', this.onHashchange); + } + if (this.onBeforeUnload) { + window.removeEventListener('beforeunload', this.onBeforeUnload); + } + if (this.onPagehide) { + window.removeEventListener('pagehide', this.onPagehide); + } + if (this.onScroll) { + window.removeEventListener('scroll', this.onScroll); + } + } + if (typeof document !== 'undefined') { + if (this.onClick) { + document.removeEventListener('click', this.onClick, true); + } + if (this.onVisibilityChange) { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } + } + } } export default BehaviorPlugin; diff --git a/src/plugins/ErrorPlugin.ts b/src/plugins/ErrorPlugin.ts index 3b7a787..84f5d3a 100644 --- a/src/plugins/ErrorPlugin.ts +++ b/src/plugins/ErrorPlugin.ts @@ -1,15 +1,19 @@ import type { LightPlugin, LightClient, ErrorEvent, StackFrame, LightConfig } from '../types'; -import { now, isObject } from '../utils/helper'; +import { now, isObject, matchPatterns } from '../utils/helper'; import { computeFingerprint } from '../utils/hash'; -import { shouldIgnoreUrl } from '../utils/env'; +import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env'; +import { parseStackTrace } from '../utils/stacktrace'; interface ErrorPluginConfig { maxStackFrames?: number; captureNodeModules?: boolean; captureColumn?: boolean; relativePathOnly?: boolean; + sampleRate?: number; + resourceSampleRate?: number; ignoreErrors?: (string | RegExp)[]; ignoreUrls?: (string | RegExp)[]; + includePaths?: (string | RegExp)[]; } class ErrorPlugin implements LightPlugin { @@ -22,8 +26,13 @@ class ErrorPlugin implements LightPlugin { captureNodeModules: false, captureColumn: true, relativePathOnly: false, + resourceSampleRate: 0.1, }; + private originalOnError?: typeof window.onerror; + private onUnhandledRejection?: (event: PromiseRejectionEvent) => void; + private onResourceError?: (event: Event) => void; + setup(client: LightClient): void { this.client = client; this.loadConfig(); @@ -43,31 +52,61 @@ class ErrorPlugin implements LightPlugin { if (clientConfig.ignoreUrls) { this.config.ignoreUrls = [...(this.config.ignoreUrls || []), ...clientConfig.ignoreUrls]; } + if (clientConfig.includePaths) { + this.config.includePaths = [...(this.config.includePaths || []), ...clientConfig.includePaths]; + } } private shouldIgnoreError(message: string): boolean { - if (!this.config.ignoreErrors || !message) return false; - return this.config.ignoreErrors.some(pattern => { - if (typeof pattern === 'string') { - return message.includes(pattern); - } - return pattern.test(message); - }); + if (!message) return false; + return matchPatterns(message, this.config.ignoreErrors || []); } private shouldIgnoreScriptUrl(url: string): boolean { - return shouldIgnoreUrl(url, this.config.ignoreUrls); + return shouldIgnoreUrl(url, this.config.ignoreUrls || []); + } + + private shouldIncludePath(frames: StackFrame[]): boolean { + const patterns = this.config.includePaths || []; + if (patterns.length === 0) return true; + + return frames.some(frame => + patterns.some(pattern => { + if (typeof pattern === 'string') { + return frame.filename?.includes(pattern); + } + return pattern.test(frame.filename || ''); + }) + ); + } + + private shouldSample(): boolean { + const rate = this.config.sampleRate ?? 1; + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; + } + + private shouldSampleResource(): boolean { + const rate = this.config.resourceSampleRate ?? 0.1; + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; } private setupGlobalError(): void { if (typeof window === 'undefined') return; - const originalOnError = window.onerror; + this.originalOnError = window.onerror; window.onerror = (message, url, lineno, colno, error) => { try { - if (originalOnError) { - originalOnError.call(window, message, url, lineno, colno, error); + if (this.originalOnError) { + this.originalOnError.call(window, message, url, lineno, colno, error); + } + + if (url && this.shouldIgnoreScriptUrl(url as string)) { + return false; } const errorEvent = this.buildErrorEvent( @@ -91,9 +130,15 @@ class ErrorPlugin implements LightPlugin { private setupUnhandledRejection(): void { if (typeof window === 'undefined') return; - window.addEventListener('unhandledrejection', (event) => { + this.onUnhandledRejection = (event) => { try { const reason = event.reason; + const sourceUrl = this.extractUrlFromReason(reason); + + if (sourceUrl && this.shouldIgnoreScriptUrl(sourceUrl)) { + return; + } + const errorEvent = this.buildErrorEvent(reason); if (errorEvent) { @@ -110,13 +155,29 @@ class ErrorPlugin implements LightPlugin { } catch (e) { console.error('[LightSDK] ErrorPlugin unhandledrejection handler error:', e); } - }); + }; + + window.addEventListener('unhandledrejection', this.onUnhandledRejection); + } + + private extractUrlFromReason(reason: unknown): string | null { + if (reason instanceof Error && reason.stack) { + const match = reason.stack.match(/at\s+(?:.+?\s+)?\((https?:\/\/[^:]+):\d+:\d+\)/); + if (match) { + return match[1]; + } + const urlMatch = reason.stack.match(/(https?:\/\/[^\s)]+):\d+:\d+/); + if (urlMatch) { + return urlMatch[1]; + } + } + return null; } private setupResourceError(): void { if (typeof window === 'undefined') return; - window.addEventListener('error', (event) => { + this.onResourceError = (event) => { const target = event.target; if (!target) return; @@ -125,14 +186,20 @@ class ErrorPlugin implements LightPlugin { if (tagName && src && ['img', 'script', 'link', 'audio', 'video'].includes(tagName)) { try { + if (this.shouldIgnoreScriptUrl(src)) return; + if (!this.shouldSampleResource()) return; + + const fingerprint = `resource:${tagName}`; + const sanitizedSrc = sanitizeUrl(src); this.client.captureEvent({ type: 'error', level: 'warning', - message: `Resource load failed: ${tagName} ${src}`, + message: `Resource load failed: ${tagName} ${sanitizedSrc}`, timestamp: now(), + fingerprint, tags: { resource_type: tagName, - resource_url: src, + resource_url: sanitizedSrc, }, exception: { type: 'ResourceError', @@ -143,7 +210,9 @@ class ErrorPlugin implements LightPlugin { console.error('[LightSDK] ErrorPlugin resource error handler error:', e); } } - }, true); + }; + + window.addEventListener('error', this.onResourceError, true); } private buildErrorEvent(error: Error | unknown, url?: string, lineno?: number, colno?: number): Partial & { type: string } | null { @@ -152,7 +221,16 @@ class ErrorPlugin implements LightPlugin { if (error instanceof Error) { if (this.shouldIgnoreError(error.message)) return null; - const frames = this.parseStackTrace(error.stack); + const frames = parseStackTrace(error.stack, { + captureNodeModules: this.config.captureNodeModules, + captureColumn: this.config.captureColumn, + relativePathOnly: this.config.relativePathOnly, + maxFrames: this.config.maxStackFrames, + }); + + if (!this.shouldIncludePath(frames)) return null; + if (!this.shouldSample()) return null; + const fingerprint = computeFingerprint(error.name, error.message, frames); return { @@ -164,7 +242,7 @@ class ErrorPlugin implements LightPlugin { type: error.name, value: error.message, stacktrace: { - frames: frames.slice(0, this.config.maxStackFrames || 5), + frames, }, }, fingerprint, @@ -184,6 +262,9 @@ class ErrorPlugin implements LightPlugin { }); } + if (!this.shouldIncludePath(frames)) return null; + if (!this.shouldSample()) return null; + return { type: 'error', level: 'error', @@ -199,64 +280,19 @@ class ErrorPlugin implements LightPlugin { }; } - private parseStackTrace(stack?: string): StackFrame[] { - if (!stack) return []; - - const frames: StackFrame[] = []; - const lines = stack.split('\n'); - const origin = typeof location !== 'undefined' ? `${location.protocol}//${location.host}` : ''; - - for (const line of lines) { - const match = line.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/); - if (match) { - const [, fn, filename, lineNum, colNum] = match; - - const isNodeModules = filename.includes('node_modules'); - if (!this.config.captureNodeModules && isNodeModules) { - continue; - } - - let finalFilename = filename; - if (this.config.relativePathOnly && origin && filename.startsWith(origin)) { - finalFilename = filename.substring(origin.length); - } - - frames.push({ - filename: finalFilename, - function: fn, - lineno: parseInt(lineNum, 10), - colno: this.config.captureColumn ? parseInt(colNum, 10) : undefined, - in_app: !isNodeModules, - }); - } else { - const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/); - if (urlMatch) { - const [, filename, lineNum, colNum] = urlMatch; - - const isNodeModules = filename.includes('node_modules'); - if (!this.config.captureNodeModules && isNodeModules) { - continue; - } - - let finalFilename = filename; - if (this.config.relativePathOnly && origin && filename.startsWith(origin)) { - finalFilename = filename.substring(origin.length); - } - - frames.push({ - filename: finalFilename, - lineno: parseInt(lineNum, 10), - colno: this.config.captureColumn ? parseInt(colNum, 10) : undefined, - in_app: !isNodeModules, - }); - } + destroy(): void { + if (typeof window !== 'undefined') { + if (this.originalOnError !== undefined) { + window.onerror = this.originalOnError; + } + if (this.onUnhandledRejection) { + window.removeEventListener('unhandledrejection', this.onUnhandledRejection); + } + if (this.onResourceError) { + window.removeEventListener('error', this.onResourceError, true); } } - - return frames.reverse(); } - - destroy(): void {} } export default ErrorPlugin; diff --git a/src/plugins/NetworkPlugin.ts b/src/plugins/NetworkPlugin.ts index fa35b9e..5e3140f 100644 --- a/src/plugins/NetworkPlugin.ts +++ b/src/plugins/NetworkPlugin.ts @@ -4,6 +4,8 @@ import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env'; interface NetworkPluginConfig { ignoreUrls?: (string | RegExp)[]; + captureXHR?: boolean; + captureFetch?: boolean; captureSuccess?: boolean; captureBody?: boolean; captureRequestBody?: boolean; @@ -23,6 +25,8 @@ class NetworkPlugin implements LightPlugin { private client!: LightClient; private config: NetworkPluginConfig = { + captureXHR: true, + captureFetch: true, captureSuccess: false, captureBody: false, captureRequestBody: false, @@ -43,8 +47,12 @@ class NetworkPlugin implements LightPlugin { setup(client: LightClient): void { this.client = client; this.loadConfig(); - this.patchFetch(); - this.patchXHR(); + if (this.config.captureFetch) { + this.patchFetch(); + } + if (this.config.captureXHR) { + this.patchXHR(); + } } private loadConfig(): void { @@ -151,6 +159,7 @@ class NetworkPlugin implements LightPlugin { XMLHttpRequest.prototype.open = function (method: string, url: string | URL) { (this as { _lightMethod?: string })._lightMethod = method; (this as { _lightUrl?: string })._lightUrl = typeof url === 'string' ? url : url.toString(); + (this as { _lightTracked?: boolean })._lightTracked = false; return self.originalXHROpen.apply(this, arguments as unknown as Parameters); }; @@ -158,67 +167,59 @@ class NetworkPlugin implements LightPlugin { const startTime = now(); const method = (this as { _lightMethod?: string })._lightMethod || 'GET'; const url = (this as { _lightUrl?: string })._lightUrl || ''; + const tracked = (this as { _lightTracked?: boolean })._lightTracked; - if (self.shouldIgnore(url)) { + if (self.shouldIgnore(url) || tracked) { return self.originalXHRSend.apply(this, arguments as unknown as Parameters); } + (this as { _lightTracked?: boolean })._lightTracked = true; const sanitizedUrl = sanitizeUrl(url); let requestSize = 0; if (body && typeof body === 'string') { requestSize = body.length; } - const onLoadEnd = () => { + const originalOnLoadEnd = (this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend; + (this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend = function (ev: ProgressEvent) { const duration = now() - startTime; const status = this.status; const isSuccess = status >= 200 && status < 300; - if (self.config.ignoreStatusCodes?.includes(status)) { - this.removeEventListener('loadend', onLoadEnd); - return; - } - - if (!self.shouldSample(!isSuccess, duration)) { - this.removeEventListener('loadend', onLoadEnd); - return; - } - - if (isSuccess && !self.config.captureSuccess) { - this.removeEventListener('loadend', onLoadEnd); - return; - } - - let responseSize = 0; - try { - const sizeHeader = this.getResponseHeader('content-length'); - if (sizeHeader) { - responseSize = parseInt(sizeHeader, 10); + if (!self.config.ignoreStatusCodes?.includes(status) + && self.shouldSample(!isSuccess, duration) + && (isSuccess ? self.config.captureSuccess : true)) { + let responseSize = 0; + try { + const sizeHeader = this.getResponseHeader('content-length'); + if (sizeHeader) { + responseSize = parseInt(sizeHeader, 10); + } + if (!responseSize && this.responseText) { + responseSize = this.responseText.length; + } + } catch { + // ignore } - if (!responseSize && this.responseText) { - responseSize = this.responseText.length; - } - } catch { - // ignore + + self.reportNetwork({ + sub_type: 'xhr', + method: method.toUpperCase(), + url: sanitizedUrl, + status_code: status, + duration, + request_size: requestSize || undefined, + response_size: responseSize || undefined, + success: isSuccess, + error: !isSuccess ? `HTTP ${status}` : undefined, + }); } - self.reportNetwork({ - sub_type: 'xhr', - method: method.toUpperCase(), - url: sanitizedUrl, - status_code: status, - duration, - request_size: requestSize || undefined, - response_size: responseSize || undefined, - success: isSuccess, - error: !isSuccess ? `HTTP ${status}` : undefined, - }); - - this.removeEventListener('loadend', onLoadEnd); + if (originalOnLoadEnd) { + return originalOnLoadEnd.call(this, ev); + } }; - this.addEventListener('loadend', onLoadEnd); - return self.originalXHRSend.apply(this, arguments as unknown as Parameters); }; } diff --git a/src/plugins/OfflinePlugin.ts b/src/plugins/OfflinePlugin.ts index 96a7748..eb789bf 100644 --- a/src/plugins/OfflinePlugin.ts +++ b/src/plugins/OfflinePlugin.ts @@ -20,6 +20,8 @@ class OfflinePlugin implements LightPlugin { private db: IDBDatabase | null = null; private isOnline: boolean = true; private isSyncing: boolean = false; + private onOnline?: () => void; + private onOffline?: () => void; private pendingEvents: SentryEvent[] = []; setup(client: LightClient): void { @@ -57,10 +59,15 @@ class OfflinePlugin implements LightPlugin { request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; + const currentVersion = event.oldVersion; + if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); store.createIndex('timestamp', 'timestamp', { unique: false }); } + + // 未来版本迁移可以在这里处理: + // if (currentVersion < 2) { ... } }; }); } @@ -70,16 +77,19 @@ class OfflinePlugin implements LightPlugin { this.isOnline = navigator.onLine; - window.addEventListener('online', () => { + this.onOnline = () => { console.info('[LightSDK] OfflinePlugin: Network online'); this.isOnline = true; this.syncPendingEvents(); - }); + }; - window.addEventListener('offline', () => { + this.onOffline = () => { console.info('[LightSDK] OfflinePlugin: Network offline'); this.isOnline = false; - }); + }; + + window.addEventListener('online', this.onOnline); + window.addEventListener('offline', this.onOffline); } private generateId(): string { @@ -89,32 +99,41 @@ class OfflinePlugin implements LightPlugin { private async saveToIndexedDB(event: SentryEvent): Promise { if (!this.db) return; + const maxEvents = this.config.maxEvents || MAX_EVENTS; + + const count = await this.getEventCount(); + if (count >= maxEvents) { + await this.deleteOldestEvents(count - maxEvents + 1); + } + return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); - // 先检查数量 - const countRequest = store.count(); - countRequest.onsuccess = async () => { - const count = countRequest.result; - if (count >= (this.config.maxEvents || MAX_EVENTS)) { - // 删除最旧的事件 - await this.deleteOldestEvents(count - (this.config.maxEvents || MAX_EVENTS) + 1); - } - - const offlineEvent: OfflineEvent = { - id: this.generateId(), - event: { - ...event, - offline: true, // 标记为离线事件 - } as SentryEvent, - timestamp: now(), - }; - - const addRequest = store.add(offlineEvent); - addRequest.onsuccess = () => resolve(); - addRequest.onerror = () => reject(addRequest.error); + const offlineEvent: OfflineEvent = { + id: this.generateId(), + event: { + ...event, + offline: true, + } as unknown as SentryEvent, + timestamp: now(), }; + + const addRequest = store.add(offlineEvent); + addRequest.onsuccess = () => resolve(); + addRequest.onerror = () => reject(addRequest.error); + }); + } + + private getEventCount(): Promise { + if (!this.db) return Promise.resolve(0); + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const countRequest = store.count(); + countRequest.onsuccess = () => resolve(countRequest.result); + countRequest.onerror = () => reject(countRequest.error); }); } @@ -184,42 +203,43 @@ class OfflinePlugin implements LightPlugin { console.info('[LightSDK] OfflinePlugin: Syncing pending events'); try { - const storedEvents = await this.getAllStoredEvents(); + let syncedCount = 0; - if (storedEvents.length === 0) { - console.info('[LightSDK] OfflinePlugin: No pending events to sync'); - this.isSyncing = false; - return; + // 先处理内存中的事件 + while (this.pendingEvents.length > 0) { + const event = this.pendingEvents.shift() as Partial & { type: string }; + try { + this.client.captureEvent(event); + syncedCount++; + } catch (error) { + console.error('[LightSDK] OfflinePlugin: Failed to re-enqueue memory event', error); + } } - // 分批上报,每次最多 50 条 - const batchSize = 50; - for (let i = 0; i < storedEvents.length; i += batchSize) { - const batch = storedEvents.slice(i, i + batchSize); - const events = batch.map(e => e.event); - - try { - // 使用客户端的上报接口 - const flush = async () => { - return new Promise((resolve, reject) => { - // 创建一个临时的上报函数 - // 注意:这里需要通过 EventBus 触发上报 - this.client.emit('sync:offline', events); - resolve(); - }); - }; - - await flush(); - console.info(`[LightSDK] OfflinePlugin: Synced ${batch.length} events`); - } catch (error) { - console.error('[LightSDK] OfflinePlugin: Failed to sync batch', error); - // 继续同步其他批次 + // 再处理 IndexedDB 中的事件 + const storedEvents = await this.getAllStoredEvents(); + if (storedEvents.length > 0) { + for (const offlineEvent of storedEvents) { + try { + const event = offlineEvent.event as Partial & { type: string }; + this.client.captureEvent(event); + syncedCount++; + } catch (error) { + console.error('[LightSDK] OfflinePlugin: Failed to re-enqueue event', error); + } } } + // 等待队列 flush + try { + await this.client.flush(); + } catch (e) { + // flush 失败不影响,事件已在队列中会自动重试 + } + // 清空已同步的事件 await this.clearStoredEvents(); - console.info('[LightSDK] OfflinePlugin: All pending events synced'); + console.info(`[LightSDK] OfflinePlugin: Synced ${syncedCount} events`); } catch (error) { console.error('[LightSDK] OfflinePlugin: Sync failed', error); } finally { @@ -229,22 +249,51 @@ class OfflinePlugin implements LightPlugin { beforeReport(event: SentryEvent): SentryEvent | null { if (!this.isOnline) { - // 离线时保存到 IndexedDB - this.saveToIndexedDB(event).catch(err => { - console.error('[LightSDK] OfflinePlugin: Failed to save event offline', err); - }); - // 返回 null 表示不上报(因为已经缓存了) + const maxEvents = this.config.maxEvents || MAX_EVENTS; + if (this.pendingEvents.length >= maxEvents) { + this.pendingEvents.shift(); + } + this.pendingEvents.push(event); + this.savePendingEventsToDB(); return null; } - // 在线时,标记为非离线事件 const onlineEvent = { ...event }; delete (onlineEvent as Record).offline; return onlineEvent as SentryEvent; } + private savePendingEventsToDB(): void { + if (this.pendingEvents.length === 0) return; + + const events = this.pendingEvents.splice(0, this.pendingEvents.length); + let savedCount = 0; + + const saveNext = () => { + if (savedCount >= events.length) return; + const event = events[savedCount]; + this.saveToIndexedDB(event).then(() => { + savedCount++; + saveNext(); + }).catch(err => { + console.error('[LightSDK] OfflinePlugin: Failed to save event offline', err); + // 恢复剩余未保存的事件 + this.pendingEvents.unshift(...events.slice(savedCount)); + }); + }; + + saveNext(); + } + destroy(): void { - // 清理资源 + if (typeof window !== 'undefined') { + if (this.onOnline) { + window.removeEventListener('online', this.onOnline); + } + if (this.onOffline) { + window.removeEventListener('offline', this.onOffline); + } + } if (this.db) { this.db.close(); this.db = null; diff --git a/src/plugins/PerformancePlugin.ts b/src/plugins/PerformancePlugin.ts index 209640f..1001dcb 100644 --- a/src/plugins/PerformancePlugin.ts +++ b/src/plugins/PerformancePlugin.ts @@ -26,14 +26,19 @@ class PerformancePlugin implements LightPlugin { captureFP: true, }; + private observers: PerformanceObserver[] = []; + private onLoad?: () => void; + private navLoadTimer: ReturnType | null = null; + private totalBlockingTime: number = 0; + private fcpTime: number | null = null; + private lastLongTaskEndTime: number = 0; + private ttiReported: boolean = false; + private ttiTimer: ReturnType | null = null; + setup(client: LightClient): void { this.client = client; this.loadConfig(); - if (!this.shouldSample('default')) { - return; - } - this.observeWebVitals(); this.observeLongTasks(); this.observeNavigation(); @@ -47,12 +52,14 @@ class PerformancePlugin implements LightPlugin { } } - private shouldSample(type: 'default' | 'resource' | 'longtask' = 'default'): boolean { + private shouldSample(type: 'default' | 'resource' | 'longtask' | 'vitals' = 'default'): boolean { let rate = this.config.sampleRate ?? 0.1; if (type === 'resource') { rate = this.config.resourceSampleRate ?? 0.01; } else if (type === 'longtask') { rate = this.config.longTaskSampleRate ?? 0.05; + } else if (type === 'vitals') { + rate = 1; } if (rate >= 1) return true; if (rate <= 0) return false; @@ -77,10 +84,11 @@ class PerformancePlugin implements LightPlugin { const po = new PerformanceObserver((entryList) => { const entries = entryList.getEntriesByName('first-paint'); if (entries.length > 0) { - this.reportMetric('FP', entries[0].startTime, 'ms'); + this.reportMetric('FP', entries[0].startTime, 'ms', {}, 'vitals'); } }); po.observe({ type: 'paint', buffered: true }); + this.observers.push(po); } catch { // FP not supported } @@ -93,10 +101,11 @@ class PerformancePlugin implements LightPlugin { const lastEntry = entries[entries.length - 1] as PerformanceEntry & { renderTime?: number; loadTime?: number }; if (lastEntry) { const value = lastEntry.renderTime || lastEntry.loadTime || lastEntry.startTime; - this.reportMetric('LCP', value, 'ms'); + this.reportMetric('LCP', value, 'ms', {}, 'vitals'); } }); po.observe({ type: 'largest-contentful-paint', buffered: true }); + this.observers.push(po); } catch { // LCP not supported } @@ -109,10 +118,11 @@ class PerformancePlugin implements LightPlugin { if (entries.length > 0) { const firstEntry = entries[0]; const value = firstEntry.processingStart - firstEntry.startTime; - this.reportMetric('FID', value, 'ms'); + this.reportMetric('FID', value, 'ms', {}, 'vitals'); } }); po.observe({ type: 'first-input', buffered: true }); + this.observers.push(po); } catch { // FID not supported } @@ -125,7 +135,7 @@ class PerformancePlugin implements LightPlugin { let sessionEntries: PerformanceEntry[] = []; const po = new PerformanceObserver((entryList) => { - const entries = entryList.getEntries() as PerformanceEntry & { hadRecentInput?: boolean; value?: number }[]; + const entries = entryList.getEntries() as unknown as { hadRecentInput?: boolean; value?: number; startTime: number; endTime?: number }[]; for (const entry of entries) { if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0]; @@ -133,24 +143,25 @@ class PerformancePlugin implements LightPlugin { if ( sessionValue && - entry.startTime - (lastSessionEntry as { endTime?: number }).endTime! < 1000 && + entry.startTime - (lastSessionEntry?.duration || 0) < 1000 && entry.startTime - firstSessionEntry.startTime < 5000 ) { sessionValue += entry.value || 0; - sessionEntries.push(entry as PerformanceEntry); + sessionEntries.push(entry as unknown as PerformanceEntry); } else { sessionValue = entry.value || 0; - sessionEntries = [entry as PerformanceEntry]; + sessionEntries = [entry as unknown as PerformanceEntry]; } if (sessionValue > clsValue) { clsValue = sessionValue; - this.reportMetric('CLS', clsValue, ''); + this.reportMetric('CLS', clsValue, '', {}, 'vitals'); } } } }); po.observe({ type: 'layout-shift', buffered: true }); + this.observers.push(po); } catch { // CLS not supported } @@ -161,10 +172,14 @@ class PerformancePlugin implements LightPlugin { const po = new PerformanceObserver((entryList) => { const entries = entryList.getEntriesByName('first-contentful-paint'); if (entries.length > 0) { - this.reportMetric('FCP', entries[0].startTime, 'ms'); + this.fcpTime = entries[0].startTime; + this.lastLongTaskEndTime = entries[0].startTime; + this.reportMetric('FCP', entries[0].startTime, 'ms', {}, 'vitals'); + this.scheduleTTI(); } }); po.observe({ type: 'paint', buffered: true }); + this.observers.push(po); } catch { // FCP not supported } @@ -177,10 +192,11 @@ class PerformancePlugin implements LightPlugin { if (navEntries.length > 0) { const navEntry = navEntries[0]; const ttfb = navEntry.responseStart - navEntry.requestStart; - this.reportMetric('TTFB', Math.max(0, ttfb), 'ms'); + this.reportMetric('TTFB', Math.max(0, ttfb), 'ms', {}, 'vitals'); } }); po.observe({ type: 'navigation', buffered: true }); + this.observers.push(po); } catch { // Navigation timing not supported } @@ -193,23 +209,53 @@ class PerformancePlugin implements LightPlugin { const po = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); for (const entry of entries) { - if (this.shouldSample('longtask')) { - this.reportMetric('longtask', entry.duration, 'ms'); + const endTime = entry.startTime + entry.duration; + if (endTime > this.lastLongTaskEndTime) { + this.lastLongTaskEndTime = endTime; } + + if (this.fcpTime !== null && entry.startTime >= this.fcpTime) { + const blockingTime = entry.duration - 50; + if (blockingTime > 0) { + this.totalBlockingTime += blockingTime; + } + } + + this.reportMetric('longtask', entry.duration, 'ms', {}, 'longtask'); + + this.scheduleTTI(); } }); po.observe({ type: 'longtask', buffered: true }); + this.observers.push(po); } catch { // Long tasks not supported } } + private scheduleTTI(): void { + if (this.ttiReported) return; + + if (this.ttiTimer) { + clearTimeout(this.ttiTimer); + } + + this.ttiTimer = setTimeout(() => { + if (this.ttiReported) return; + this.ttiReported = true; + + const tti = Math.max(this.lastLongTaskEndTime, this.fcpTime || 0); + this.reportMetric('TTI', tti, 'ms', {}, 'vitals'); + this.reportMetric('TBT', this.totalBlockingTime, 'ms', {}, 'vitals'); + }, 5000); + } + private observeNavigation(): void { if (!this.config.captureNavigation) return; if (typeof performance === 'undefined') return; - window.addEventListener('load', () => { - setTimeout(() => { + this.onLoad = () => { + this.navLoadTimer = setTimeout(() => { const timing = performance.timing; if (!timing) return; @@ -230,8 +276,11 @@ class PerformancePlugin implements LightPlugin { this.reportMetric(name, value, 'ms'); } } + this.navLoadTimer = null; }, 0); - }); + }; + + window.addEventListener('load', this.onLoad); } private observeResources(): void { @@ -240,19 +289,18 @@ class PerformancePlugin implements LightPlugin { try { const po = new PerformanceObserver((entryList) => { - if (!this.shouldSample('resource')) return; - const entries = entryList.getEntriesByType('resource') as PerformanceResourceTiming[]; for (const entry of entries) { if (entry.duration > 1000) { this.reportMetric('resource_slow', entry.duration, 'ms', { resource_name: entry.name.substring(0, 200), resource_type: entry.initiatorType, - }); + }, 'resource'); } } }); po.observe({ type: 'resource', buffered: true }); + this.observers.push(po); } catch { // Resource timing not supported } @@ -276,7 +324,9 @@ class PerformancePlugin implements LightPlugin { return 'poor'; } - private reportMetric(name: string, value: number, unit: string, extraTags: Record = {}): void { + private reportMetric(name: string, value: number, unit: string, extraTags: Record = {}, sampleType: 'default' | 'resource' | 'longtask' | 'vitals' = 'default'): void { + if (!this.shouldSample(sampleType)) return; + const rating = this.getRating(name, value); const event: PerformanceEvent = { @@ -297,7 +347,30 @@ class PerformancePlugin implements LightPlugin { this.client.captureEvent(event); } - destroy(): void {} + destroy(): void { + for (const observer of this.observers) { + try { + observer.disconnect(); + } catch { + // ignore + } + } + this.observers = []; + + if (this.ttiTimer) { + clearTimeout(this.ttiTimer); + this.ttiTimer = null; + } + + if (this.navLoadTimer) { + clearTimeout(this.navLoadTimer); + this.navLoadTimer = null; + } + + if (typeof window !== 'undefined' && this.onLoad) { + window.removeEventListener('load', this.onLoad); + } + } } export default PerformancePlugin; diff --git a/src/plugins/SamplingPlugin.ts b/src/plugins/SamplingPlugin.ts new file mode 100644 index 0000000..0771c3a --- /dev/null +++ b/src/plugins/SamplingPlugin.ts @@ -0,0 +1,87 @@ +import type { LightPlugin, LightClient, SentryEvent, LightConfig } from '../types'; +import { isObject } from '../utils/helper'; + +interface SampleRateConfig { + error?: number; + performance?: number; + network?: number; + behavior?: number; + [key: string]: number | undefined; +} + +interface SamplingPluginConfig { + rates?: SampleRateConfig; +} + +class SamplingPlugin implements LightPlugin { + name = 'sampling'; + version = '1.0.0'; + + private client!: LightClient; + private config: SamplingPluginConfig = { + rates: { + error: 1, + performance: 1, + network: 1, + behavior: 1, + }, + }; + + setup(client: LightClient): void { + this.client = client; + this.loadConfig(); + } + + private loadConfig(): void { + const clientConfig = this.client.config as LightConfig; + if (clientConfig && isObject(clientConfig.sampling)) { + const samplingConfig = clientConfig.sampling as object; + if ('rates' in samplingConfig && isObject((samplingConfig as { rates?: object }).rates)) { + this.config.rates = { ...this.config.rates, ...((samplingConfig as { rates: object }).rates as object) } as SampleRateConfig; + } + } + } + + private shouldSample(event: SentryEvent): boolean { + const globalRate = (this.client.config as LightConfig).sampleRate ?? 1; + if (globalRate <= 0) return false; + + const typeRate = this.getTypeRate(event); + + if (globalRate >= 1) { + return this.randomCheck(typeRate); + } + return this.randomCheck(globalRate * typeRate); + } + + private getTypeRate(event: SentryEvent): number { + const rates = this.config.rates || {}; + const type = event.type; + + if (type === 'count') { + return 1; + } + + const rate = rates[type]; + return rate !== undefined ? rate : 1; + } + + private randomCheck(rate: number): boolean { + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; + } + + beforeReport(event: SentryEvent): SentryEvent | null { + if (!this.shouldSample(event)) { + return null; + } + return event; + } + + destroy(): void { + // nothing to clean up + } +} + +export default SamplingPlugin; diff --git a/src/types/index.ts b/src/types/index.ts index 4647180..7e5ba3a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,12 @@ export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug'; +export interface ContextLevelConfig { + maxStackFrames: number; + maxBreadcrumbs: number; +} + +export type ContextLevelMap = Record; + export interface UserInfo { id?: string; username?: string; @@ -131,6 +138,16 @@ export interface DSNInfo { projectId: string; } +export interface SamplingConfig { + rates?: { + error?: number; + performance?: number; + network?: number; + behavior?: number; + [key: string]: number | undefined; + }; +} + export interface LightConfig { dsn: string; release?: string; @@ -144,9 +161,12 @@ export interface LightConfig { ignoreErrors?: (string | RegExp)[]; ignoreUrls?: (string | RegExp)[]; includePaths?: (string | RegExp)[]; + maxBreadcrumbs?: number; beforeSend?: (event: SentryEvent) => SentryEvent | null; user?: UserInfo; plugins?: (LightPlugin | string)[]; + contextLevel?: Partial; + sampling?: SamplingConfig; [pluginName: string]: unknown; } @@ -176,4 +196,7 @@ export interface LightClient { flush(): Promise; disable(): void; enable(): void; + use(plugin: LightPlugin): void; + init(): void; + destroy(): void; } diff --git a/src/utils/dsn.ts b/src/utils/dsn.ts index acffe82..5fe487a 100644 --- a/src/utils/dsn.ts +++ b/src/utils/dsn.ts @@ -21,9 +21,9 @@ export function parseDSN(dsn: string): DSNInfo { } export function getEnvelopeUrl(dsn: DSNInfo): string { - return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/envelope/`; + return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}`; } export function getStoreUrl(dsn: DSNInfo): string { - return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/store/`; + return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/store/?sentry_key=${dsn.publicKey}`; } diff --git a/src/utils/env.ts b/src/utils/env.ts index 6deb008..a4456fa 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -186,6 +186,25 @@ export function sanitizeUrl(url: string): string { } } + if (urlObj.hash && urlObj.hash.length > 1) { + const hashContent = urlObj.hash.substring(1); + if (hashContent.includes('=')) { + const hashParams = new URLSearchParams(hashContent); + let hashModified = false; + for (const key of Array.from(hashParams.keys())) { + const lowerKey = key.toLowerCase().replace(/[-_]/g, '_'); + if (sensitiveParams.some(s => lowerKey.includes(s))) { + hashParams.set(key, '[Filtered]'); + hashModified = true; + } + } + if (hashModified) { + urlObj.hash = '#' + hashParams.toString(); + modified = true; + } + } + } + if (modified) { return urlObj.toString(); } diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 246b45b..43783a5 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -8,16 +8,9 @@ export function hashString(str: string): number { } export function md5(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash).toString(16).padStart(8, '0') + - Math.abs(hash * 31).toString(16).padStart(8, '0') + - Math.abs(hash * 17).toString(16).padStart(8, '0') + - Math.abs(hash * 7).toString(16).padStart(8, '0'); + // 使用 hashString 替代不安全的 md5 + // 用于非加密场景(ID生成、指纹计算) + return hashString(str).toString(16); } export function computeFingerprint(type: string, message: string, frames: { filename: string; lineno?: number }[] = []): string { diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 05ed89b..378a96b 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -40,3 +40,13 @@ export function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str; return str.slice(0, maxLen) + '...'; } + +export function matchPatterns(value: string, patterns: (string | RegExp)[]): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some(pattern => { + if (typeof pattern === 'string') { + return value.includes(pattern); + } + return pattern.test(value); + }); +} diff --git a/src/utils/stacktrace.ts b/src/utils/stacktrace.ts new file mode 100644 index 0000000..dd0abc4 --- /dev/null +++ b/src/utils/stacktrace.ts @@ -0,0 +1,100 @@ +import type { StackFrame } from '../types'; + +export interface ParseStackOptions { + captureNodeModules?: boolean; + captureColumn?: boolean; + relativePathOnly?: boolean; + maxFrames?: number; +} + +export function parseStackTrace(stack?: string, options: ParseStackOptions = {}): StackFrame[] { + if (!stack) return []; + + const { + captureNodeModules = false, + captureColumn = true, + relativePathOnly = false, + maxFrames, + } = options; + + const frames: StackFrame[] = []; + const lines = stack.split('\n'); + const origin = typeof location !== 'undefined' ? `${location.protocol}//${location.host}` : ''; + + for (const line of lines) { + const match = line.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/); + if (match) { + const [, fn, filename, lineNum, colNum] = match; + + const isNodeModules = filename.includes('node_modules'); + if (!captureNodeModules && isNodeModules) { + continue; + } + + let finalFilename = filename; + if (relativePathOnly && origin && filename.startsWith(origin)) { + finalFilename = filename.substring(origin.length); + } + + frames.push({ + filename: finalFilename, + function: fn, + lineno: parseInt(lineNum, 10), + colno: captureColumn ? parseInt(colNum, 10) : undefined, + in_app: !isNodeModules, + }); + } else { + const v8Match = line.match(/^\s*at\s*(.+?)@(.+?):(\d+):(\d+)\s*$/); + if (v8Match) { + const [, fn, filename, lineNum, colNum] = v8Match; + + const isNodeModules = filename.includes('node_modules'); + if (!captureNodeModules && isNodeModules) { + continue; + } + + let finalFilename = filename; + if (relativePathOnly && origin && filename.startsWith(origin)) { + finalFilename = filename.substring(origin.length); + } + + frames.push({ + filename: finalFilename, + function: fn || '', + lineno: parseInt(lineNum, 10), + colno: captureColumn ? parseInt(colNum, 10) : undefined, + in_app: !isNodeModules, + }); + continue; + } + + const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/); + if (urlMatch) { + const [, filename, lineNum, colNum] = urlMatch; + + const isNodeModules = filename.includes('node_modules'); + if (!captureNodeModules && isNodeModules) { + continue; + } + + let finalFilename = filename; + if (relativePathOnly && origin && filename.startsWith(origin)) { + finalFilename = filename.substring(origin.length); + } + + frames.push({ + filename: finalFilename, + lineno: parseInt(lineNum, 10), + colno: captureColumn ? parseInt(colNum, 10) : undefined, + in_app: !isNodeModules, + }); + } + } + } + + const reversed = frames.reverse(); + if (maxFrames !== undefined && maxFrames > 0) { + return reversed.slice(0, maxFrames); + } + return reversed; +} diff --git a/tsconfig.json b/tsconfig.json index 0ca4bc0..c9b4420 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2018", "module": "ESNext", "moduleResolution": "node", - "lib": ["ES2018", "DOM", "DOM.Iterable"], + "lib": ["ES2019", "DOM", "DOM.Iterable"], "strict": true, "noImplicitAny": true, "strictNullChecks": true,