@blueking/open-telemetry 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +0 -0
  2. package/dist/bk-rum.global.js +11 -0
  3. package/dist/bk-rum.global.js.map +1 -0
  4. package/dist/index.js +1186 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +49 -0
  7. package/playground/App.vue +148 -0
  8. package/playground/bk-ot.ts +110 -0
  9. package/playground/components/DemoLogPanel.vue +89 -0
  10. package/playground/composables/use-demo-log.ts +39 -0
  11. package/playground/index.html +13 -0
  12. package/playground/main.ts +36 -0
  13. package/playground/mock/index.ts +43 -0
  14. package/playground/pages/BlankScreenDemo.vue +97 -0
  15. package/playground/pages/BusinessDemo.vue +74 -0
  16. package/playground/pages/CustomPluginDemo.vue +104 -0
  17. package/playground/pages/ErrorDemo.vue +75 -0
  18. package/playground/pages/Home.vue +133 -0
  19. package/playground/pages/HttpDemo.vue +120 -0
  20. package/playground/pages/LongTaskDemo.vue +66 -0
  21. package/playground/pages/WebSocketDemo.vue +100 -0
  22. package/playground/router/index.ts +92 -0
  23. package/playground/style.css +180 -0
  24. package/src/browser.ts +44 -0
  25. package/src/core/config.ts +327 -0
  26. package/src/core/plugin.ts +97 -0
  27. package/src/core/processor.ts +74 -0
  28. package/src/core/route-observer.ts +128 -0
  29. package/src/core/sampling.ts +64 -0
  30. package/src/core/sdk.ts +327 -0
  31. package/src/core/url.ts +66 -0
  32. package/src/index.ts +55 -0
  33. package/src/plugins/blank-screen.ts +170 -0
  34. package/src/plugins/common.ts +131 -0
  35. package/src/plugins/csp-violation.ts +74 -0
  36. package/src/plugins/device.ts +91 -0
  37. package/src/plugins/error.ts +211 -0
  38. package/src/plugins/http-body.ts +437 -0
  39. package/src/plugins/long-task.ts +99 -0
  40. package/src/plugins/page-view.ts +83 -0
  41. package/src/plugins/route-timing.ts +89 -0
  42. package/src/plugins/session.ts +127 -0
  43. package/src/plugins/web-vitals.ts +159 -0
  44. package/src/plugins/websocket.ts +179 -0
  45. package/tsconfig.app.json +27 -0
  46. package/tsconfig.dts.json +13 -0
  47. package/tsconfig.json +7 -0
  48. package/tsconfig.node.json +26 -0
  49. package/vite.config.ts +70 -0
@@ -0,0 +1,437 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making
3
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) available.
4
+ *
5
+ * Copyright (C) 2017-2025 Tencent. All rights reserved.
6
+ *
7
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) is licensed under the MIT License.
8
+ *
9
+ * License for 蓝鲸智云PaaS平台 (BlueKing PaaS):
10
+ *
11
+ * ---------------------------------------------------
12
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
13
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation
14
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
15
+ * to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+ *
17
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of
18
+ * the Software.
19
+ *
20
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
21
+ * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
23
+ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24
+ * IN THE SOFTWARE.
25
+ */
26
+
27
+ import { SeverityNumber } from '@opentelemetry/api-logs';
28
+
29
+ import { shouldIgnoreUrl } from '../core/url';
30
+
31
+ import type { BkOTHttpBodyConfig, BkOTHttpBodyRedactPayload } from '../core/config';
32
+ import type { BkOTPlugin, BkOTRuntimeContext } from '../core/plugin';
33
+ import type { Attributes } from '@opentelemetry/api';
34
+
35
+ interface BodySnapshot {
36
+ body: string;
37
+ contentType?: string;
38
+ truncated: boolean;
39
+ }
40
+
41
+ interface ReportHttpBodyOptions {
42
+ context: BkOTRuntimeContext;
43
+ duration: number;
44
+ error?: unknown;
45
+ method: string;
46
+ request?: BodySnapshot;
47
+ response?: BodySnapshot;
48
+ status?: number;
49
+ url: string;
50
+ }
51
+
52
+ const HTTP_BODY_MAX_SIZE = 10 * 1024;
53
+
54
+ type HttpBodyInput = BodyInit | Document | null | undefined;
55
+
56
+ const truncateBody = (body: string, maxBodySize: number): Pick<BodySnapshot, 'body' | 'truncated'> => {
57
+ if (body.length <= maxBodySize) {
58
+ return {
59
+ body,
60
+ truncated: false,
61
+ };
62
+ }
63
+ return {
64
+ body: body.slice(0, maxBodySize),
65
+ truncated: true,
66
+ };
67
+ };
68
+
69
+ const getHeaderValue = (headers: HeadersInit | undefined, key: string) => {
70
+ if (!headers) {
71
+ return undefined;
72
+ }
73
+ if (headers instanceof Headers) {
74
+ return headers.get(key) || undefined;
75
+ }
76
+ const normalizedKey = key.toLowerCase();
77
+ if (Array.isArray(headers)) {
78
+ return headers.find(([itemKey]) => itemKey.toLowerCase() === normalizedKey)?.[1];
79
+ }
80
+ const matchedKey = Object.keys(headers).find(item => item.toLowerCase() === normalizedKey);
81
+ return matchedKey ? headers[matchedKey] : undefined;
82
+ };
83
+
84
+ const stringifyFormData = (body: FormData) => {
85
+ const entries: Record<string, string> = {};
86
+ body.forEach((value, key) => {
87
+ entries[key] =
88
+ value instanceof File ? `[File name=${value.name} type=${value.type || 'unknown'} size=${value.size}]` : value;
89
+ });
90
+ return JSON.stringify(entries);
91
+ };
92
+
93
+ const stringifyBody = async (body: HttpBodyInput): Promise<string> => {
94
+ if (body == null) {
95
+ return '';
96
+ }
97
+ if (typeof body === 'string') {
98
+ return body;
99
+ }
100
+ if (body instanceof URLSearchParams) {
101
+ return body.toString();
102
+ }
103
+ if (body instanceof FormData) {
104
+ return stringifyFormData(body);
105
+ }
106
+ if (body instanceof Blob) {
107
+ return body.text();
108
+ }
109
+ if (body instanceof ArrayBuffer) {
110
+ return new TextDecoder().decode(body);
111
+ }
112
+ if (ArrayBuffer.isView(body)) {
113
+ return new TextDecoder().decode(body);
114
+ }
115
+ if (body instanceof Document) {
116
+ return new XMLSerializer().serializeToString(body);
117
+ }
118
+ return String(body);
119
+ };
120
+
121
+ const createBodySnapshot = async (
122
+ body: HttpBodyInput,
123
+ maxBodySize: number,
124
+ contentType?: string,
125
+ ): Promise<BodySnapshot | undefined> => {
126
+ const rawBody = await stringifyBody(body);
127
+ if (!rawBody) {
128
+ return undefined;
129
+ }
130
+ return {
131
+ ...truncateBody(rawBody, maxBodySize),
132
+ contentType,
133
+ };
134
+ };
135
+
136
+ const safeCreateBodySnapshot = async (body: HttpBodyInput, maxBodySize: number, contentType?: string) => {
137
+ try {
138
+ return await createBodySnapshot(body, maxBodySize, contentType);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ };
143
+
144
+ const getFetchInputUrl = (input: RequestInfo | URL) => {
145
+ if (input instanceof Request) {
146
+ return input.url;
147
+ }
148
+ return String(input);
149
+ };
150
+
151
+ const getFetchMethod = (input: RequestInfo | URL, init?: RequestInit) => {
152
+ if (init?.method) {
153
+ return init.method;
154
+ }
155
+ if (input instanceof Request) {
156
+ return input.method;
157
+ }
158
+ return 'GET';
159
+ };
160
+
161
+ const getFetchRequestBody = async (input: RequestInfo | URL, init: RequestInit | undefined, maxBodySize: number) => {
162
+ if (init?.body) {
163
+ return safeCreateBodySnapshot(
164
+ init.body,
165
+ maxBodySize,
166
+ getHeaderValue(init.headers as Headers | undefined, 'content-type'),
167
+ );
168
+ }
169
+ if (input instanceof Request) {
170
+ try {
171
+ return safeCreateBodySnapshot(
172
+ await input.clone().text(),
173
+ maxBodySize,
174
+ input.headers.get('content-type') || undefined,
175
+ );
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ return undefined;
181
+ };
182
+
183
+ const getResponseBody = async (response: Response, maxBodySize: number) => {
184
+ try {
185
+ const clonedResponse = response.clone();
186
+ return safeCreateBodySnapshot(
187
+ clonedResponse.body ? await clonedResponse.text() : '',
188
+ maxBodySize,
189
+ response.headers.get('content-type') || undefined,
190
+ );
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ };
195
+
196
+ const getXhrResponseBody = (xhr: XMLHttpRequest, maxBodySize: number): BodySnapshot | undefined => {
197
+ if (xhr.responseType === '' || xhr.responseType === 'text') {
198
+ return {
199
+ ...truncateBody(xhr.responseText || '', maxBodySize),
200
+ contentType: xhr.getResponseHeader('content-type') || undefined,
201
+ };
202
+ }
203
+ if (xhr.responseType === 'json') {
204
+ return {
205
+ ...truncateBody(JSON.stringify(xhr.response), maxBodySize),
206
+ contentType: xhr.getResponseHeader('content-type') || undefined,
207
+ };
208
+ }
209
+ return undefined;
210
+ };
211
+
212
+ const redactBody = (
213
+ config: Required<BkOTHttpBodyConfig>,
214
+ payload: Omit<BkOTHttpBodyRedactPayload, 'body' | 'truncated'>,
215
+ snapshot?: BodySnapshot,
216
+ ) => {
217
+ if (!snapshot) {
218
+ return undefined;
219
+ }
220
+ return config.redact({
221
+ ...payload,
222
+ body: snapshot.body,
223
+ truncated: snapshot.truncated,
224
+ });
225
+ };
226
+
227
+ const reportHttpBody = ({
228
+ context,
229
+ duration,
230
+ error,
231
+ method,
232
+ request,
233
+ response,
234
+ status,
235
+ url,
236
+ }: ReportHttpBodyOptions) => {
237
+ const config = context.config.rum.httpBody;
238
+ if (!config) {
239
+ return;
240
+ }
241
+
242
+ const isError = Boolean(error) || (typeof status === 'number' && status >= 400);
243
+ const normalizedMethod = method.toUpperCase();
244
+ const requestBody = isError
245
+ ? redactBody(
246
+ config,
247
+ {
248
+ contentType: request?.contentType,
249
+ method: normalizedMethod,
250
+ status,
251
+ type: 'request',
252
+ url,
253
+ },
254
+ request,
255
+ )
256
+ : undefined;
257
+ const responseBody = isError
258
+ ? redactBody(
259
+ config,
260
+ {
261
+ contentType: response?.contentType,
262
+ method: normalizedMethod,
263
+ status,
264
+ type: 'response',
265
+ url,
266
+ },
267
+ response,
268
+ )
269
+ : undefined;
270
+ const attributes: Attributes = {
271
+ ...context.config.getPageAttributes(),
272
+ 'http.duration': duration,
273
+ 'http.request.method': normalizedMethod,
274
+ 'url.full': context.config.redactUrl(url),
275
+ };
276
+
277
+ if (typeof status === 'number') {
278
+ attributes['http.response.status_code'] = status;
279
+ }
280
+ if (requestBody != null) {
281
+ attributes['http.request.body'] = requestBody;
282
+ }
283
+ if (responseBody != null) {
284
+ attributes['http.response.body'] = responseBody;
285
+ }
286
+ if (isError) {
287
+ attributes['bk.rum.http_body.request.truncated'] = request?.truncated ?? false;
288
+ attributes['bk.rum.http_body.response.truncated'] = response?.truncated ?? false;
289
+ }
290
+ if (error instanceof Error) {
291
+ attributes['exception.message'] = error.message;
292
+ attributes['exception.type'] = error.name;
293
+ attributes['exception.stacktrace'] = error.stack ?? '';
294
+ }
295
+
296
+ context.emitLog({
297
+ severityNumber: isError ? SeverityNumber.ERROR : SeverityNumber.INFO,
298
+ severityText: isError ? 'ERROR' : 'INFO',
299
+ body: isError ? 'HTTP request completed with error' : 'HTTP request completed',
300
+ attributes,
301
+ });
302
+ };
303
+
304
+ export const createHttpBodyPlugin = (option: false | Required<BkOTHttpBodyConfig>): BkOTPlugin => {
305
+ const teardownCallbacks: Array<() => void> = [];
306
+
307
+ return {
308
+ name: 'http-body',
309
+ enabled: Boolean(option),
310
+ init(context) {
311
+ if (!option || typeof window === 'undefined') {
312
+ return;
313
+ }
314
+
315
+ const maxBodySize = option.maxBodySize || HTTP_BODY_MAX_SIZE;
316
+ const originalFetch = window.fetch;
317
+ if (typeof originalFetch === 'function') {
318
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
319
+ const url = getFetchInputUrl(input);
320
+ if (shouldIgnoreUrl(context.config, url)) {
321
+ return originalFetch(input, init);
322
+ }
323
+ const method = getFetchMethod(input, init);
324
+ const request = await getFetchRequestBody(input, init, maxBodySize);
325
+ const startTime = performance.now();
326
+ try {
327
+ const response = await originalFetch(input, init);
328
+ const duration = performance.now() - startTime;
329
+ const responseBody = response.status >= 400 ? await getResponseBody(response, maxBodySize) : undefined;
330
+ reportHttpBody({
331
+ context,
332
+ duration,
333
+ method,
334
+ request,
335
+ response: responseBody,
336
+ status: response.status,
337
+ url,
338
+ });
339
+ return response;
340
+ } catch (error) {
341
+ reportHttpBody({
342
+ context,
343
+ duration: performance.now() - startTime,
344
+ error,
345
+ method,
346
+ request,
347
+ url,
348
+ });
349
+ throw error;
350
+ }
351
+ };
352
+ teardownCallbacks.push(() => {
353
+ window.fetch = originalFetch;
354
+ });
355
+ }
356
+
357
+ const originalOpen = XMLHttpRequest.prototype.open;
358
+ const originalSend = XMLHttpRequest.prototype.send;
359
+ const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
360
+ XMLHttpRequest.prototype.open = function open(
361
+ this: XMLHttpRequest,
362
+ method: string,
363
+ url: string | URL,
364
+ ...args: [async?: boolean, username?: null | string, password?: null | string]
365
+ ) {
366
+ this.__bkOtHttpBodyMeta__ = {
367
+ method,
368
+ startTime: performance.now(),
369
+ url: String(url),
370
+ };
371
+ return originalOpen.call(this, method, url, ...args);
372
+ };
373
+ XMLHttpRequest.prototype.setRequestHeader = function setRequestHeader(
374
+ this: XMLHttpRequest,
375
+ name: string,
376
+ value: string,
377
+ ) {
378
+ this.__bkOtHttpBodyRequestHeaders__ = {
379
+ ...(this.__bkOtHttpBodyRequestHeaders__ ?? {}),
380
+ [name]: value,
381
+ };
382
+ return originalSetRequestHeader.call(this, name, value);
383
+ };
384
+ XMLHttpRequest.prototype.send = function send(
385
+ this: XMLHttpRequest,
386
+ body?: Document | null | XMLHttpRequestBodyInit,
387
+ ) {
388
+ void safeCreateBodySnapshot(
389
+ body,
390
+ maxBodySize,
391
+ getHeaderValue(this.__bkOtHttpBodyRequestHeaders__, 'content-type'),
392
+ ).then(request => {
393
+ this.__bkOtHttpBodyRequest__ = request;
394
+ });
395
+ this.addEventListener('loadend', () => {
396
+ const meta = this.__bkOtHttpBodyMeta__;
397
+ if (!meta || shouldIgnoreUrl(context.config, meta.url)) {
398
+ return;
399
+ }
400
+ const responseBody = this.status >= 400 ? getXhrResponseBody(this, maxBodySize) : undefined;
401
+ reportHttpBody({
402
+ context,
403
+ duration: performance.now() - meta.startTime,
404
+ method: meta.method,
405
+ request: this.__bkOtHttpBodyRequest__,
406
+ response: responseBody,
407
+ status: this.status,
408
+ url: meta.url,
409
+ });
410
+ });
411
+ return originalSend.call(this, body);
412
+ };
413
+ teardownCallbacks.push(() => {
414
+ XMLHttpRequest.prototype.open = originalOpen;
415
+ XMLHttpRequest.prototype.send = originalSend;
416
+ XMLHttpRequest.prototype.setRequestHeader = originalSetRequestHeader;
417
+ });
418
+ },
419
+ shutdown() {
420
+ while (teardownCallbacks.length) {
421
+ teardownCallbacks.pop()?.();
422
+ }
423
+ },
424
+ };
425
+ };
426
+
427
+ declare global {
428
+ interface XMLHttpRequest {
429
+ __bkOtHttpBodyRequest__?: BodySnapshot;
430
+ __bkOtHttpBodyRequestHeaders__?: Record<string, string>;
431
+ __bkOtHttpBodyMeta__?: {
432
+ method: string;
433
+ startTime: number;
434
+ url: string;
435
+ };
436
+ }
437
+ }
@@ -0,0 +1,99 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making
3
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) available.
4
+ *
5
+ * Copyright (C) 2017-2025 Tencent. All rights reserved.
6
+ *
7
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) is licensed under the MIT License.
8
+ *
9
+ * License for 蓝鲸智云PaaS平台 (BlueKing PaaS):
10
+ *
11
+ * ---------------------------------------------------
12
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
13
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation
14
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
15
+ * to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+ *
17
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of
18
+ * the Software.
19
+ *
20
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
21
+ * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
23
+ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24
+ * IN THE SOFTWARE.
25
+ */
26
+
27
+ import type { BkOTRumConfig } from '../core/config';
28
+ import type { BkOTPlugin } from '../core/plugin';
29
+
30
+ const DEFAULT_THRESHOLD_MS = 50;
31
+
32
+ interface LongTaskAttribution {
33
+ containerId?: string;
34
+ containerName?: string;
35
+ containerSrc?: string;
36
+ containerType?: string;
37
+ name?: string;
38
+ }
39
+
40
+ interface LongTaskEntry extends PerformanceEntry {
41
+ attribution?: LongTaskAttribution[];
42
+ }
43
+
44
+ /**
45
+ * Long Task 监控:通过 PerformanceObserver type=longtask 捕获 >= threshold 的主线程长任务。
46
+ * 默认关闭,建议在性能敏感模块按需开启。
47
+ */
48
+ export const createLongTaskPlugin = (option: BkOTRumConfig['longTask']): BkOTPlugin => {
49
+ let observer: PerformanceObserver | undefined;
50
+
51
+ return {
52
+ name: 'long-task',
53
+ enabled: Boolean(option),
54
+ init(context) {
55
+ if (typeof PerformanceObserver === 'undefined') {
56
+ return;
57
+ }
58
+ const entryTypes = (PerformanceObserver as unknown as { supportedEntryTypes?: string[] }).supportedEntryTypes;
59
+ if (!entryTypes?.includes('longtask')) {
60
+ return;
61
+ }
62
+
63
+ const threshold = typeof option === 'object' ? (option.threshold ?? DEFAULT_THRESHOLD_MS) : DEFAULT_THRESHOLD_MS;
64
+ const counter = context.meter.createCounter('browser.long_task.count', {
65
+ description: 'Number of long tasks observed (duration >= threshold)',
66
+ });
67
+ const durationHistogram = context.meter.createHistogram('browser.long_task.duration', {
68
+ unit: 'ms',
69
+ description: 'Duration of long tasks',
70
+ });
71
+
72
+ try {
73
+ observer = new PerformanceObserver(list => {
74
+ for (const entry of list.getEntries() as LongTaskEntry[]) {
75
+ if (entry.duration < threshold) {
76
+ continue;
77
+ }
78
+ const attribution = entry.attribution?.[0];
79
+ const dimensions = context.applyRedact({
80
+ ...context.config.getPageAttributes(),
81
+ ...context.config.getMetricAttributes(),
82
+ 'long_task.name': entry.name,
83
+ 'long_task.attribution': attribution?.name ?? 'unknown',
84
+ });
85
+ counter.add(1, dimensions);
86
+ durationHistogram.record(entry.duration, dimensions);
87
+ }
88
+ });
89
+ observer.observe({ type: 'longtask', buffered: true });
90
+ } catch {
91
+ observer = undefined;
92
+ }
93
+ },
94
+ shutdown() {
95
+ observer?.disconnect();
96
+ observer = undefined;
97
+ },
98
+ };
99
+ };
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making
3
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) available.
4
+ *
5
+ * Copyright (C) 2017-2025 Tencent. All rights reserved.
6
+ *
7
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) is licensed under the MIT License.
8
+ *
9
+ * License for 蓝鲸智云PaaS平台 (BlueKing PaaS):
10
+ *
11
+ * ---------------------------------------------------
12
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
13
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation
14
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
15
+ * to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+ *
17
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of
18
+ * the Software.
19
+ *
20
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
21
+ * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
23
+ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24
+ * IN THE SOFTWARE.
25
+ */
26
+
27
+ import { subscribeRouteChange } from '../core/route-observer';
28
+
29
+ import type { BkOTRumConfig } from '../core/config';
30
+ import type { BkOTPlugin } from '../core/plugin';
31
+
32
+ const getCurrentUrl = () => {
33
+ if (typeof location === 'undefined') {
34
+ return '';
35
+ }
36
+ return location.href;
37
+ };
38
+
39
+ export const createPageViewPlugin = (enabled: BkOTRumConfig['pageView']): BkOTPlugin => {
40
+ const teardownCallbacks: Array<() => void> = [];
41
+
42
+ return {
43
+ name: 'page-view',
44
+ enabled: Boolean(enabled),
45
+ init(context) {
46
+ if (typeof window === 'undefined') {
47
+ return;
48
+ }
49
+
50
+ // 同 URL 去重:路由库常常因 query/hash 微调反复 push 同一 URL,去重避免重复上报
51
+ let lastUrl = '';
52
+
53
+ const emitPageView = (source: string, previousUrl = lastUrl) => {
54
+ const currentUrl = getCurrentUrl();
55
+ if (currentUrl === lastUrl) {
56
+ return;
57
+ }
58
+ lastUrl = currentUrl;
59
+
60
+ const span = context.startSpan('browser.page_view', {
61
+ ...context.config.getPageAttributes(),
62
+ 'url.full': context.config.redactUrl(currentUrl),
63
+ 'url.previous': context.config.redactUrl(previousUrl),
64
+ 'document.referrer': context.config.redactUrl(document.referrer || ''),
65
+ 'bk.rum.event.source': source,
66
+ });
67
+ span.end();
68
+ };
69
+
70
+ const unsubscribe = subscribeRouteChange(event => {
71
+ emitPageView(event.source, event.fromUrl);
72
+ });
73
+ emitPageView('load');
74
+
75
+ teardownCallbacks.push(unsubscribe);
76
+ },
77
+ shutdown() {
78
+ while (teardownCallbacks.length) {
79
+ teardownCallbacks.pop()?.();
80
+ }
81
+ },
82
+ };
83
+ };
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making
3
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) available.
4
+ *
5
+ * Copyright (C) 2017-2025 Tencent. All rights reserved.
6
+ *
7
+ * 蓝鲸智云PaaS平台 (BlueKing PaaS) is licensed under the MIT License.
8
+ *
9
+ * License for 蓝鲸智云PaaS平台 (BlueKing PaaS):
10
+ *
11
+ * ---------------------------------------------------
12
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
13
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation
14
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
15
+ * to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+ *
17
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of
18
+ * the Software.
19
+ *
20
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
21
+ * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
23
+ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24
+ * IN THE SOFTWARE.
25
+ */
26
+
27
+ import { subscribeRouteChange } from '../core/route-observer';
28
+
29
+ import type { BkOTRumConfig } from '../core/config';
30
+ import type { BkOTPlugin } from '../core/plugin';
31
+
32
+ /**
33
+ * 估算 SPA 路由切换耗时:路由变更 → 双 raf + 一个宏任务后视为"渲染完成"。
34
+ * 不依赖具体框架,但仅是粗粒度估算;如需精确,业务可走自定义 plugin 在框架钩子内 startSpan/end。
35
+ */
36
+ export const createRouteTimingPlugin = (enabled: BkOTRumConfig['routeTiming']): BkOTPlugin => {
37
+ const teardownCallbacks: Array<() => void> = [];
38
+
39
+ return {
40
+ name: 'route-timing',
41
+ enabled: Boolean(enabled),
42
+ init(context) {
43
+ if (typeof window === 'undefined' || typeof history === 'undefined') {
44
+ return;
45
+ }
46
+
47
+ const histogram = context.meter.createHistogram('browser.route_change.duration', {
48
+ unit: 'ms',
49
+ description: 'Estimated SPA route change duration (route start to next idle)',
50
+ });
51
+
52
+ const measure = (source: string, fromUrl: string, toUrl: string) => {
53
+ const startedAt = performance.now();
54
+ const finalize = () => {
55
+ const duration = performance.now() - startedAt;
56
+ const dimensions = context.applyRedact({
57
+ ...context.config.getPageAttributes(),
58
+ ...context.config.getMetricAttributes(),
59
+ 'route.change.source': source,
60
+ });
61
+ histogram.record(duration, dimensions);
62
+ const span = context.startSpan('browser.route_change', {
63
+ ...dimensions,
64
+ 'url.full': context.config.redactUrl(toUrl),
65
+ 'url.previous': context.config.redactUrl(fromUrl),
66
+ 'route.change.duration_ms': duration,
67
+ });
68
+ span.end();
69
+ };
70
+ // 双 raf 等到下一帧渲染后,再补一个宏任务,覆盖大多数同步组件挂载场景
71
+ requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(finalize, 0)));
72
+ };
73
+
74
+ const unsubscribe = subscribeRouteChange(event => {
75
+ if (event.fromUrl === event.toUrl) {
76
+ return;
77
+ }
78
+ measure(event.source, event.fromUrl, event.toUrl);
79
+ });
80
+
81
+ teardownCallbacks.push(unsubscribe);
82
+ },
83
+ shutdown() {
84
+ while (teardownCallbacks.length) {
85
+ teardownCallbacks.pop()?.();
86
+ }
87
+ },
88
+ };
89
+ };