sunda-tracker 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ import { moment } from '@/utils/helpers'
2
+
3
+ import { DEFAULT_ES_OPTIONS } from '@/utils/constants'
4
+
5
+ export default class ElasticsearchSender {
6
+ /**
7
+ * @param {Object} config 配置项
8
+ * @param {string} config.node ES服务器地址
9
+ * @param {string} config.index 索引名称
10
+ * @param {string} [config.username] 用户名
11
+ * @param {string} [config.password] 密码
12
+ * @param {number} [config.batchSize=100] 批量发送大小
13
+ * @param {number} [config.flushInterval=5000] 自动刷新间隔(ms)
14
+ */
15
+ constructor(config) {
16
+ this.config = {
17
+ batchSize: 100,
18
+ flushInterval: 5000,
19
+ ...DEFAULT_ES_OPTIONS,
20
+ ...config
21
+ }
22
+
23
+ this.queue = []
24
+ this.headers = this.buildHeaders()
25
+ this.timer = null
26
+ this.startAutoFlush()
27
+ }
28
+
29
+ buildHeaders() {
30
+ const headers = {
31
+ 'Content-Type': 'application/x-ndjson',
32
+ Accept: 'application/json'
33
+ }
34
+
35
+ const auth = this.config.encoded
36
+ headers['Authorization'] = `ApiKey ${auth}`
37
+
38
+ return headers
39
+ }
40
+
41
+ /**
42
+ * 发送单条数据
43
+ * @param {Object} data
44
+ */
45
+ async send(data) {
46
+ try {
47
+ const docWithTimestamp = {
48
+ ...data,
49
+ '@timestamp': new Date().toISOString()
50
+ }
51
+
52
+ this.queue.push(docWithTimestamp)
53
+
54
+ if (this.queue.length >= this.config.batchSize) {
55
+ await this.flush()
56
+ }
57
+
58
+ return true
59
+ } catch (error) {
60
+ console.error('Error sending data to Elasticsearch:', error)
61
+ return false
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 批量发送数据
67
+ * @param {Array} dataArray 数据数组
68
+ */
69
+ async sendBulk(dataArray) {
70
+ try {
71
+ const timestamp = new Date().toISOString()
72
+ const docsWithTimestamp = dataArray.map((data) => ({
73
+ ...data,
74
+ '@timestamp': timestamp
75
+ }))
76
+
77
+ this.queue.push(...docsWithTimestamp)
78
+
79
+ if (this.queue.length >= this.config.batchSize) {
80
+ await this.flush()
81
+ }
82
+
83
+ return true
84
+ } catch (error) {
85
+ console.error('Error sending bulk data to Elasticsearch:', error)
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 构建批量请求体
92
+ * @param {Array} documents 文档数组
93
+ * @returns {string} NDJSON格式的请求体
94
+ */
95
+ buildBulkRequestBody(documents) {
96
+ return (
97
+ documents
98
+ .flatMap((doc) => [
99
+ JSON.stringify({
100
+ index: {
101
+ _index: `${this.config.index}-${moment().format('YYYY.MM.DD')}`
102
+ }
103
+ }),
104
+ JSON.stringify(doc)
105
+ ])
106
+ .join('\n') + '\n'
107
+ )
108
+ }
109
+
110
+ async flush() {
111
+ if (this.queue.length === 0) return
112
+
113
+ const documents = [...this.queue]
114
+ this.queue = []
115
+
116
+ try {
117
+ const body = this.buildBulkRequestBody(documents)
118
+ const response = await fetch(
119
+ `${this.config.node}/${this.config.index}-${moment().format(
120
+ 'YYYY.MM.DD'
121
+ )}/_bulk`,
122
+ {
123
+ method: 'POST',
124
+ headers: this.headers,
125
+ body: body
126
+ }
127
+ )
128
+
129
+ if (!response.ok) {
130
+ throw new Error(`HTTP error! status: ${response.status}`)
131
+ }
132
+
133
+ const result = await response.json()
134
+
135
+ if (result.errors) {
136
+ const failedItems = result.items.filter(
137
+ (item) => item.index.status >= 400
138
+ )
139
+ if (failedItems.length > 0) {
140
+ console.error('Some documents failed to index:', failedItems)
141
+ this.handleFailedDocuments(failedItems, documents)
142
+ }
143
+ }
144
+
145
+ return true
146
+ } catch (error) {
147
+ this.stopAutoFlush()
148
+ console.error('Error flushing data to Elasticsearch:', error)
149
+ this.queue.unshift(...documents)
150
+ return false
151
+ }
152
+ }
153
+
154
+ handleFailedDocuments(failedItems, originalDocuments) {
155
+ failedItems.forEach((item) => {
156
+ const failedDoc = originalDocuments[item.index._id]
157
+ this.storeFailedDocument(failedDoc)
158
+ })
159
+ }
160
+
161
+ storeFailedDocument(document) {
162
+ try {
163
+ const failedDocs = JSON.parse(
164
+ localStorage.getItem('es_failed_docs') || '[]'
165
+ )
166
+ failedDocs.push({
167
+ document,
168
+ timestamp: new Date().toISOString(),
169
+ retryCount: 0
170
+ })
171
+ localStorage.setItem('es_failed_docs', JSON.stringify(failedDocs))
172
+ } catch (error) {
173
+ console.error('Error storing failed document:', error)
174
+ }
175
+ }
176
+
177
+ async retryFailedDocuments() {
178
+ try {
179
+ const failedDocs = JSON.parse(
180
+ localStorage.getItem('es_failed_docs') || '[]'
181
+ )
182
+ if (failedDocs.length === 0) return
183
+
184
+ const docsToRetry = failedDocs.filter((doc) => doc.retryCount < 3)
185
+ const remainingDocs = failedDocs.filter((doc) => doc.retryCount >= 3)
186
+ docsToRetry.forEach((doc) => doc.retryCount++)
187
+
188
+ await this.sendBulk(docsToRetry.map((doc) => doc.document))
189
+ localStorage.setItem('es_failed_docs', JSON.stringify(remainingDocs))
190
+ } catch (error) {
191
+ console.error('Error retrying failed documents:', error)
192
+ }
193
+ }
194
+
195
+ startAutoFlush() {
196
+ this.timer = setInterval(() => {
197
+ if (this.queue.length > 0) {
198
+ this.flush()
199
+ }
200
+ this.retryFailedDocuments()
201
+ }, this.config.flushInterval)
202
+ }
203
+
204
+ stopAutoFlush() {
205
+ if (this.timer) {
206
+ clearInterval(this.timer)
207
+ this.timer = null
208
+ }
209
+ }
210
+
211
+ destroy() {
212
+ this.stopAutoFlush()
213
+ return this.flush()
214
+ }
215
+ }
@@ -0,0 +1,21 @@
1
+ export default class Queue {
2
+ constructor(options) {
3
+ this.options = options
4
+ this.cache = []
5
+ this.retryQueue = new Map()
6
+ }
7
+
8
+ async send(data) {
9
+ // TODO:
10
+ console.log('send track data to server ====>', data)
11
+ }
12
+
13
+ addToRetryQueue(data) {
14
+ // TODO: 重试队列逻辑
15
+ console.log('retry ====>', data)
16
+ }
17
+
18
+ async processRetryQueue() {
19
+ // TODO: 处理重试队列
20
+ }
21
+ }
@@ -0,0 +1,34 @@
1
+ export default class Storage {
2
+ constructor({ prefix = 'track_' }) {
3
+ this.prefix = prefix
4
+ }
5
+
6
+ setItem(key, value) {
7
+ localStorage.setItem(this.prefix + key, JSON.stringify(value))
8
+ }
9
+
10
+ getItem(key) {
11
+ const value = localStorage.getItem(this.prefix + key)
12
+ return value ? JSON.parse(value) : null
13
+ }
14
+
15
+ removeItem(key) {
16
+ localStorage.removeItem(this.prefix + key)
17
+ }
18
+
19
+ getSessionId() {
20
+ return this.getItem('session_id')
21
+ }
22
+
23
+ setSessionId(sessionId) {
24
+ this.setItem('session_id', sessionId)
25
+ }
26
+
27
+ setUser(userInfo) {
28
+ this.setItem('user', userInfo)
29
+ }
30
+
31
+ getUser() {
32
+ return this.getItem('user')
33
+ }
34
+ }
@@ -0,0 +1,53 @@
1
+ import AutoTrack from '@/plugins/AutoTrack'
2
+ import Performance from '@/plugins/Performance'
3
+ import Device from './Device'
4
+ import ElasticsearchSender from './ElasticsearchSender'
5
+ import ApiSender from './ApiSender'
6
+ // import Queue from './Queue'
7
+ import Storage from './Storage'
8
+
9
+ export default class Track {
10
+ constructor(options) {
11
+ this.options = options
12
+ // this.queue = new Queue(options)
13
+ this.storage = new Storage(options)
14
+ this.device = new Device(options)
15
+ this.sender =
16
+ options.type === 'es'
17
+ ? new ElasticsearchSender(options)
18
+ : new ApiSender(options)
19
+
20
+ this.init()
21
+ }
22
+
23
+ init() {
24
+ this.sessionId = this.storage.getSessionId() || this.device.generateUUID()
25
+ this.storage.setSessionId(this.sessionId)
26
+
27
+ this.deviceInfo = this.device.getInfo()
28
+ this.initPlugins()
29
+ // this.initListeners()
30
+ }
31
+
32
+ setUser(userInfo) {
33
+ this.storage.setUser(userInfo)
34
+ }
35
+
36
+ initPlugins() {
37
+ if (this.options.autoTrack) {
38
+ this.autoTrack = new AutoTrack(this)
39
+ }
40
+
41
+ if (this.options.enablePerformance) {
42
+ this.performance = new Performance(this)
43
+ }
44
+ }
45
+
46
+ track(type, data) {
47
+ this.sender.send({
48
+ // type,
49
+ ...data,
50
+ sessionId: this.sessionId
51
+ })
52
+ }
53
+ }
package/src/index.js ADDED
@@ -0,0 +1,45 @@
1
+ import Track from '@/core/Track'
2
+ import { DEFAULT_OPTIONS } from '@/utils/constants'
3
+
4
+ const getInstance = (options = {}) => {
5
+ if (!window._trackInstance) {
6
+ window._trackInstance = new Track({
7
+ ...DEFAULT_OPTIONS,
8
+ ...options
9
+ })
10
+ }
11
+
12
+ return window._trackInstance
13
+ }
14
+
15
+ export default getInstance
16
+
17
+ export const VueTracker = {
18
+ install: (app, options = {}) => {
19
+ const trackInstance = getInstance(options)
20
+
21
+ app.config.globalProperties.$track = {
22
+ track(eventName, eventData) {
23
+ return trackInstance.track(eventName, eventData)
24
+ },
25
+
26
+ setUser(userInfo) {
27
+ return trackInstance.setUser(userInfo)
28
+ },
29
+
30
+ clearUser() {
31
+ return trackInstance.clearUser()
32
+ },
33
+
34
+ trackDeviceInfo() {
35
+ return trackInstance.track('device', trackInstance.deviceInfo)
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ export const TrackMixin = {
42
+ created() {
43
+ this.$trackInstance = this.$track
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ import { kebabToCamelCase } from '@/utils/helpers'
2
+
3
+ export default class AutoTrack {
4
+ constructor(track) {
5
+ this.track = track
6
+ this.init()
7
+ this.options = track.options
8
+ }
9
+
10
+ init() {
11
+ this.bindClickEvents()
12
+ this.observeRouteChanges()
13
+ }
14
+
15
+ bindClickEvents() {
16
+ document.addEventListener('click', (event) => {
17
+ const target = event.target
18
+ const trackElement = target.closest('[data-track-type]')
19
+
20
+ if (trackElement) {
21
+ const trackType = trackElement.getAttribute('data-track-type')
22
+
23
+ const trackArgs = {}
24
+ for (const attr of trackElement.attributes) {
25
+ if (attr.name.startsWith('data-track-args-')) {
26
+ const key = kebabToCamelCase(
27
+ attr.name.replace('data-track-args-', '')
28
+ )
29
+
30
+ trackArgs[key] = attr.value
31
+ }
32
+ }
33
+
34
+ if (trackType) {
35
+ this.track.track(trackType, {
36
+ ...trackArgs,
37
+ sessionId: this.track.sessionId
38
+ })
39
+ }
40
+ }
41
+ })
42
+ }
43
+
44
+ observeRouteChanges() {
45
+ // 路由变化追踪逻辑
46
+ }
47
+ }
@@ -0,0 +1,189 @@
1
+ import { throttle } from '@/utils/helpers'
2
+
3
+ export default class Performance {
4
+ constructor(track) {
5
+ this.track = track
6
+ this.metrics = {}
7
+ this.isSupported = this.checkSupport()
8
+
9
+ if (this.isSupported) {
10
+ this.init()
11
+ }
12
+ }
13
+
14
+ checkSupport() {
15
+ return (
16
+ typeof window !== 'undefined' &&
17
+ window.performance &&
18
+ window.performance.timing &&
19
+ window.performance.getEntriesByType
20
+ )
21
+ }
22
+
23
+ init() {
24
+ this.observePageLoad()
25
+ this.observeResources()
26
+ this.observeLongTasks()
27
+ this.observePaint()
28
+ this.observeErrors()
29
+ }
30
+
31
+ observePageLoad() {
32
+ window.addEventListener('load', () => {
33
+ setTimeout(() => {
34
+ const timing = performance.getEntriesByType('navigation')[0]
35
+ const metrics = {
36
+ dnsTime: timing.domainLookupEnd - timing.domainLookupStart,
37
+ tcpTime: timing.connectEnd - timing.connectStart,
38
+ sslTime:
39
+ timing.secureConnectionStart > 0 ?
40
+ timing.connectEnd - timing.secureConnectionStart :
41
+ 0,
42
+ ttfb: timing.responseStart - timing.requestStart,
43
+ responseTime: timing.responseEnd - timing.responseStart,
44
+ domParseTime: timing.domInteractive - timing.responseEnd,
45
+ domReadyTime: timing.domContentLoadedEventEnd - timing.startTime,
46
+ loadTime: timing.loadEventEnd - timing.startTime
47
+ }
48
+
49
+ this.track.track('page_performance', metrics)
50
+ }, 0)
51
+ })
52
+ }
53
+
54
+ observeResources() {
55
+ const observer = new PerformanceObserver(
56
+ throttle((list) => {
57
+ const entries = list.getEntries()
58
+ entries.forEach((entry) => {
59
+ if (
60
+ entry.initiatorType !== 'fetch' &&
61
+ entry.initiatorType !== 'xmlhttprequest'
62
+ ) {
63
+ this.track.track('resource_performance', {
64
+ name: entry.name,
65
+ type: entry.initiatorType,
66
+ duration: entry.duration,
67
+ size: entry.transferSize,
68
+ protocol: entry.nextHopProtocol
69
+ })
70
+ }
71
+ })
72
+ }, 3000)
73
+ )
74
+
75
+ observer.observe({ entryTypes: ['resource'] })
76
+ }
77
+
78
+ observeLongTasks() {
79
+ if (typeof PerformanceLongTaskTiming !== 'undefined') {
80
+ const observer = new PerformanceObserver((list) => {
81
+ list.getEntries().forEach((entry) => {
82
+ this.track.track('long_task', {
83
+ duration: entry.duration,
84
+ startTime: entry.startTime,
85
+ name: entry.name
86
+ })
87
+ })
88
+ })
89
+
90
+ observer.observe({ entryTypes: ['longtask'] })
91
+ }
92
+ }
93
+
94
+ observePaint() {
95
+ const observer = new PerformanceObserver((list) => {
96
+ const entries = list.getEntries()
97
+ entries.forEach((entry) => {
98
+ this.track.track('paint_timing', {
99
+ name: entry.name,
100
+ startTime: entry.startTime
101
+ })
102
+ })
103
+ })
104
+
105
+ observer.observe({ entryTypes: ['paint'] })
106
+ }
107
+
108
+ observeErrors() {
109
+ window.addEventListener(
110
+ 'error',
111
+ (event) => {
112
+ this.track.track('js_error', {
113
+ message: event.message,
114
+ filename: event.filename,
115
+ lineno: event.lineno,
116
+ colno: event.colno,
117
+ error: event.error ? event.error.stack : null
118
+ })
119
+ },
120
+ true
121
+ )
122
+
123
+ window.addEventListener('unhandledrejection', (event) => {
124
+ this.track.track('promise_error', {
125
+ message: event.reason.message || event.reason,
126
+ stack: event.reason.stack
127
+ })
128
+ })
129
+ }
130
+
131
+ getFirstScreenTime() {
132
+ return new Promise((resolve) => {
133
+ if (document.readyState === 'complete') {
134
+ this.calculateFirstScreen(resolve)
135
+ } else {
136
+ window.addEventListener('load', () => {
137
+ this.calculateFirstScreen(resolve)
138
+ })
139
+ }
140
+ })
141
+ }
142
+
143
+ calculateFirstScreen(callback) {
144
+ const images = document.querySelectorAll('img')
145
+ let maxTime = performance.timing.domContentLoadedEventEnd
146
+
147
+ if (images.length === 0) {
148
+ callback(maxTime)
149
+ return
150
+ }
151
+
152
+ let completed = 0
153
+ const imageLoaded = () => {
154
+ completed++
155
+ if (completed === images.length) {
156
+ callback(maxTime)
157
+ }
158
+ }
159
+
160
+ images.forEach((img) => {
161
+ if (img.complete) {
162
+ imageLoaded()
163
+ } else {
164
+ img.addEventListener('load', () => {
165
+ const loadTime = performance.now()
166
+ maxTime = Math.max(maxTime, loadTime)
167
+ imageLoaded()
168
+ })
169
+ img.addEventListener('error', imageLoaded)
170
+ }
171
+ })
172
+ }
173
+
174
+ getCurrentMetrics() {
175
+ if (!this.isSupported) return null
176
+
177
+ return {
178
+ ...this.metrics,
179
+ memory: performance.memory ?
180
+ {
181
+ usedJSHeapSize: performance.memory.usedJSHeapSize,
182
+ totalJSHeapSize: performance.memory.totalJSHeapSize
183
+ } :
184
+ null,
185
+ navigation: performance.getEntriesByType('navigation')[0],
186
+ timing: performance.timing
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_OPTIONS = {
2
+ serverUrl: '',
3
+ appId: '',
4
+ cacheLimit: 10,
5
+ autoTrack: false,
6
+ enablePerformance: false,
7
+ debug: false,
8
+ maxRetryTimes: 3,
9
+ timeout: 5000,
10
+ type: 'es', // es, api
11
+ collectUrl: '',
12
+ collectMethod: 'post'
13
+ }
14
+
15
+ export const DEFAULT_ES_OPTIONS = {
16
+ // node: import.meta.env.VITE_APP_ES_NODE,
17
+ // id: 'SEGwE48BDXHCyTKWTBXE',
18
+ // index: import.meta.env.VITE_APP_ES_INDEX_NAME,
19
+ // api_key: '3HLDi5PYTfymTnAj7MmG-w',
20
+ // encoded: import.meta.env.VITE_APP_ES_ENCODED
21
+ }