frappe-ui 1.0.0-beta.18 → 1.0.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frappe/telemetry/index.ts +88 -14
- package/frappe/telemetry/pulse.ts +78 -164
- package/frappe/telemetry/telemetry.test.ts +13 -79
- package/package.json +1 -1
- package/frappe/telemetry/utils.ts +0 -15
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { reactive, readonly, ref, type App } from 'vue'
|
|
2
|
+
import type { Router, RouteLocationNormalized } from 'vue-router'
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
4
|
+
import {
|
|
5
|
+
fetchBootConfig,
|
|
6
|
+
loadPulseClient,
|
|
7
|
+
type BootConfig,
|
|
8
|
+
type PulseAnonymousMode,
|
|
9
|
+
type PulseClient,
|
|
10
|
+
type PulseContext,
|
|
11
|
+
} from './pulse.ts'
|
|
5
12
|
|
|
6
|
-
let
|
|
13
|
+
let client: PulseClient | null = null
|
|
14
|
+
let removePageviewHook: (() => void) | null = null
|
|
7
15
|
|
|
8
16
|
const appName = ref<string>()
|
|
9
17
|
const isEnabled = ref(false)
|
|
@@ -13,18 +21,39 @@ export function useTelemetry() {
|
|
|
13
21
|
isEnabled: readonly(isEnabled),
|
|
14
22
|
disable: () => {
|
|
15
23
|
isEnabled.value = false
|
|
16
|
-
|
|
17
|
-
pulseProvider?.stop()
|
|
24
|
+
client?.stop()
|
|
18
25
|
},
|
|
19
26
|
capture: (event_name: string, data: Record<string, any> = {}) => {
|
|
20
27
|
if (!isEnabled.value || !appName.value) return
|
|
21
|
-
|
|
28
|
+
client?.capture(event_name, appName.value, data)
|
|
22
29
|
},
|
|
30
|
+
getDistinctId: () => client?.getDistinctId?.() ?? '',
|
|
23
31
|
})
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
export interface TelemetryPluginOptions {
|
|
35
|
+
app_name: string
|
|
36
|
+
host?: string
|
|
37
|
+
apiKey?: string
|
|
38
|
+
site?: string
|
|
39
|
+
enabled?: boolean
|
|
40
|
+
getContext?: () => PulseContext
|
|
41
|
+
anonymousMode?: PulseAnonymousMode
|
|
42
|
+
clientUrl?: string
|
|
43
|
+
// Pass your vue-router to auto-capture a "pageview" per navigation (new sites only,
|
|
44
|
+
// site_age <= 15 days, like desk).
|
|
45
|
+
router?: Router
|
|
46
|
+
// Route → PII-safe string. Defaults to the matched path pattern (e.g. "/orders/:id").
|
|
47
|
+
scrubRoute?: (to: RouteLocationNormalized) => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultScrubRoute(to: RouteLocationNormalized): string {
|
|
51
|
+
const matched = to.matched[to.matched.length - 1]
|
|
52
|
+
return matched?.path || to.path || ''
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
export default {
|
|
27
|
-
async install(app: App, options:
|
|
56
|
+
async install(app: App, options: TelemetryPluginOptions) {
|
|
28
57
|
appName.value = options.app_name
|
|
29
58
|
|
|
30
59
|
if (!appName.value) {
|
|
@@ -36,13 +65,58 @@ export default {
|
|
|
36
65
|
return
|
|
37
66
|
}
|
|
38
67
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
68
|
+
// Explicit options win; fetch the rest from the backend (skip when self-sufficient).
|
|
69
|
+
const fetched: BootConfig =
|
|
70
|
+
options.host != null && options.apiKey != null ? {} : await fetchBootConfig()
|
|
71
|
+
|
|
72
|
+
const getContext =
|
|
73
|
+
options.getContext || (() => ({ user: fetched.user, team: fetched.team }))
|
|
74
|
+
|
|
75
|
+
// Reinstalls (tests, HMR, SSR-per-request) reuse the module singletons, so tear
|
|
76
|
+
// down the previous client's flush timer and router hook before replacing them.
|
|
77
|
+
client?.stop()
|
|
78
|
+
removePageviewHook?.()
|
|
79
|
+
removePageviewHook = null
|
|
80
|
+
|
|
81
|
+
client = await loadPulseClient({
|
|
82
|
+
host: options.host ?? fetched.host,
|
|
83
|
+
apiKey: options.apiKey ?? fetched.key,
|
|
84
|
+
site: options.site ?? fetched.site,
|
|
85
|
+
enabled: options.enabled ?? fetched.enabled ?? false,
|
|
86
|
+
getContext,
|
|
87
|
+
anonymousMode: options.anonymousMode,
|
|
88
|
+
clientUrl: options.clientUrl ?? fetched.client_url,
|
|
89
|
+
})
|
|
90
|
+
if (!client) return
|
|
43
91
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
92
|
+
isEnabled.value = await client.init()
|
|
93
|
+
if (isEnabled.value) {
|
|
94
|
+
removePageviewHook = setupPageviewTracking(options, fetched.site_age)
|
|
95
|
+
}
|
|
47
96
|
},
|
|
48
97
|
}
|
|
98
|
+
|
|
99
|
+
// Older backends omit site_age → track anyway (permissive, matching desk's
|
|
100
|
+
// `site_age && site_age > 15` skip check).
|
|
101
|
+
function setupPageviewTracking(
|
|
102
|
+
options: TelemetryPluginOptions,
|
|
103
|
+
site_age: number | undefined,
|
|
104
|
+
): (() => void) | null {
|
|
105
|
+
if (!options.router || (site_age && site_age > 15)) return null
|
|
106
|
+
|
|
107
|
+
const scrub = options.scrubRoute || defaultScrubRoute
|
|
108
|
+
let lastFullPath = ''
|
|
109
|
+
const capturePageview = (to: RouteLocationNormalized) => {
|
|
110
|
+
// Dedupe so the initial capture and afterEach can't double-count the same route.
|
|
111
|
+
if (!isEnabled.value || !appName.value || to.fullPath === lastFullPath) return
|
|
112
|
+
lastFullPath = to.fullPath
|
|
113
|
+
client?.capture('pageview', appName.value, { route: scrub(to) })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// afterEach missed the initial navigation if it resolved during install's awaits,
|
|
117
|
+
// so capture the landing route once the router is ready.
|
|
118
|
+
options.router.isReady().then(() => {
|
|
119
|
+
if (options.router) capturePageview(options.router.currentRoute.value)
|
|
120
|
+
})
|
|
121
|
+
return options.router.afterEach((to) => capturePageview(to))
|
|
122
|
+
}
|
|
@@ -1,181 +1,95 @@
|
|
|
1
|
+
// Loads the one canonical pulse client from pulse's own CDN (not the host backend),
|
|
2
|
+
// so it's independent of the backend's framework version. Degrades to null if the
|
|
3
|
+
// asset can't be loaded, leaving telemetry off.
|
|
4
|
+
|
|
1
5
|
import { call } from 'frappe-ui'
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
captured_at: string
|
|
5
|
-
event_name: string
|
|
6
|
-
app: string
|
|
7
|
+
export interface PulseContext {
|
|
7
8
|
user?: string
|
|
8
|
-
|
|
9
|
+
team?: string
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
setEnabled(isEnabled: boolean) {
|
|
23
|
-
this.enabled = isEnabled
|
|
24
|
-
if (!this.enabled) {
|
|
25
|
-
this.eq?.stop()
|
|
26
|
-
this.eq = null
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
init() {
|
|
31
|
-
if (!this.enabled) return
|
|
32
|
-
if (this.eq) return
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
this.eq = new QueueManager((events) => this.sendEvents(events), {
|
|
36
|
-
flushInterval: 10000,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
if (!this.beforeUnloadAttached) {
|
|
40
|
-
this.beforeUnloadAttached = true
|
|
41
|
-
|
|
42
|
-
// Send remaining events on unload
|
|
43
|
-
window.addEventListener('beforeunload', () => {
|
|
44
|
-
const events = this.eq?.getBufferedEvents?.() || []
|
|
45
|
-
if (events.length) this.sendBeacon(events)
|
|
46
|
-
})
|
|
47
|
-
}
|
|
48
|
-
} catch (error: any) {
|
|
49
|
-
// ignore errors
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
capture(event: string, app: string, props?: Record<string, any>) {
|
|
54
|
-
if (!this.enabled || !this.eq) return
|
|
55
|
-
|
|
56
|
-
const user = (window as any)?.frappe?.session?.user
|
|
57
|
-
|
|
58
|
-
this.eq.add({
|
|
59
|
-
event_name: event,
|
|
60
|
-
app: app,
|
|
61
|
-
properties: props,
|
|
62
|
-
user,
|
|
63
|
-
captured_at: new Date().toISOString(),
|
|
64
|
-
})
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
private async sendEvents(events: PulseEvent[]): Promise<void> {
|
|
68
|
-
return call('frappe.utils.telemetry.pulse.client.bulk_capture', { events })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private sendBeacon(events: PulseEvent[]) {
|
|
72
|
-
try {
|
|
73
|
-
if (navigator.sendBeacon) {
|
|
74
|
-
const url =
|
|
75
|
-
'/api/method/frappe.utils.telemetry.pulse.client.bulk_capture'
|
|
76
|
-
const data = new FormData()
|
|
77
|
-
data.append('events', JSON.stringify(events))
|
|
78
|
-
navigator.sendBeacon(url, data)
|
|
79
|
-
}
|
|
80
|
-
} catch (error: any) {
|
|
81
|
-
// ignore errors
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
stop() {
|
|
86
|
-
this.eq?.stop()
|
|
87
|
-
this.eq = null
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
type QueueManagerOptions = {
|
|
92
|
-
maxQueueSize?: number
|
|
12
|
+
// "cookieless" (default): host derives the anon id at ingest. "client": mints a
|
|
13
|
+
// persistent localStorage anon id.
|
|
14
|
+
export type PulseAnonymousMode = 'cookieless' | 'client'
|
|
15
|
+
|
|
16
|
+
export interface PulseClientOptions {
|
|
17
|
+
host?: string
|
|
18
|
+
apiKey?: string
|
|
19
|
+
site?: string
|
|
20
|
+
enabled?: boolean
|
|
21
|
+
getContext?: () => PulseContext
|
|
22
|
+
anonymousMode?: PulseAnonymousMode
|
|
93
23
|
flushInterval?: number
|
|
24
|
+
maxQueueSize?: number
|
|
25
|
+
now?: () => string
|
|
26
|
+
clientUrl?: string
|
|
94
27
|
}
|
|
95
28
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
constructor(
|
|
108
|
-
flushCallback: (events: PulseEvent[]) => Promise<void>,
|
|
109
|
-
options: QueueManagerOptions = {},
|
|
110
|
-
) {
|
|
111
|
-
this.flushCallback = flushCallback
|
|
112
|
-
this.queue = []
|
|
113
|
-
this.pendingBatch = null
|
|
114
|
-
this.retryAttempts = 0
|
|
115
|
-
this.maxRetries = 3
|
|
116
|
-
this.maxQueueSize = options.maxQueueSize || 20
|
|
117
|
-
this.flushInterval = options.flushInterval || 5000
|
|
118
|
-
this.timer = null
|
|
119
|
-
this.flushing = false
|
|
120
|
-
|
|
121
|
-
this.start()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
getBufferedEvents() {
|
|
125
|
-
const events: PulseEvent[] = []
|
|
126
|
-
if (this.pendingBatch?.length) events.push(...this.pendingBatch)
|
|
127
|
-
if (this.queue.length) events.push(...this.queue)
|
|
128
|
-
return events
|
|
129
|
-
}
|
|
29
|
+
export interface PulseClient {
|
|
30
|
+
init(): Promise<boolean>
|
|
31
|
+
setEnabled(enabled: boolean): void
|
|
32
|
+
capture(event_name: string, app: string, props?: Record<string, any>): void
|
|
33
|
+
// Distinct id on events (known identity, else anon id); used to alias() pre-signup
|
|
34
|
+
// activity. Optional: an older CDN build may not expose it.
|
|
35
|
+
getDistinctId?(): string
|
|
36
|
+
flush(): Promise<void> | undefined
|
|
37
|
+
stop(): void
|
|
38
|
+
}
|
|
130
39
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
40
|
+
// Shape returned by the framework's whitelisted boot_config method.
|
|
41
|
+
export interface BootConfig {
|
|
42
|
+
enabled?: boolean
|
|
43
|
+
host?: string
|
|
44
|
+
key?: string
|
|
45
|
+
site?: string
|
|
46
|
+
user?: string
|
|
47
|
+
team?: string
|
|
48
|
+
client_url?: string
|
|
49
|
+
site_age?: number
|
|
50
|
+
}
|
|
136
51
|
|
|
137
|
-
|
|
138
|
-
this.queue.push(event)
|
|
52
|
+
export const BOOT_CONFIG_METHOD = 'frappe.utils.telemetry.pulse.client.boot_config'
|
|
139
53
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
54
|
+
// Direct-mode config from the app's own backend (frappe-ui SPAs lack desk's
|
|
55
|
+
// window.frappe.boot). Degrades to {} on any error, including old backends (404).
|
|
56
|
+
export async function fetchBootConfig(): Promise<BootConfig> {
|
|
57
|
+
try {
|
|
58
|
+
return (await call(BOOT_CONFIG_METHOD)) || {}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {}
|
|
143
61
|
}
|
|
62
|
+
}
|
|
144
63
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
64
|
+
export const DEFAULT_PULSE_CLIENT_URL =
|
|
65
|
+
'https://pulse.m.frappe.cloud/assets/pulse/js/pulse_client.js'
|
|
66
|
+
|
|
67
|
+
// Only import from a trusted origin: pulse's CDN or the ingest host (self-hosted
|
|
68
|
+
// pulse). Stops a tampered boot_config from redirecting import() to an attacker
|
|
69
|
+
// origin — an untrusted url falls back to the canonical CDN.
|
|
70
|
+
function trustedClientUrl(clientUrl: string | undefined, host?: string): string {
|
|
71
|
+
if (!clientUrl) return DEFAULT_PULSE_CLIENT_URL
|
|
72
|
+
try {
|
|
73
|
+
const target = new URL(clientUrl)
|
|
74
|
+
if (target.protocol !== 'https:') return DEFAULT_PULSE_CLIENT_URL
|
|
75
|
+
const allowed = new Set([new URL(DEFAULT_PULSE_CLIENT_URL).origin])
|
|
149
76
|
try {
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
await this.flushCallback(this.pendingBatch)
|
|
158
|
-
this.pendingBatch = null
|
|
159
|
-
this.retryAttempts = 0
|
|
160
|
-
} catch (error: any) {
|
|
161
|
-
this.retryAttempts++
|
|
162
|
-
if (this.retryAttempts > this.maxRetries) {
|
|
163
|
-
this.pendingBatch = null
|
|
164
|
-
this.retryAttempts = 0
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
} finally {
|
|
168
|
-
this.flushing = false
|
|
169
|
-
}
|
|
170
|
-
}
|
|
77
|
+
if (host) allowed.add(new URL(host).origin)
|
|
78
|
+
} catch {}
|
|
79
|
+
if (allowed.has(target.origin)) return clientUrl
|
|
80
|
+
} catch {}
|
|
81
|
+
return DEFAULT_PULSE_CLIENT_URL
|
|
82
|
+
}
|
|
171
83
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
84
|
+
export async function loadPulseClient(
|
|
85
|
+
options: PulseClientOptions = {},
|
|
86
|
+
): Promise<PulseClient | null> {
|
|
87
|
+
const { clientUrl, ...clientOptions } = options
|
|
88
|
+
try {
|
|
89
|
+
const url = trustedClientUrl(clientUrl, clientOptions.host)
|
|
90
|
+
const mod = await import(/* @vite-ignore */ url)
|
|
91
|
+
return new mod.PulseClient(clientOptions) as PulseClient
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return null
|
|
178
94
|
}
|
|
179
95
|
}
|
|
180
|
-
|
|
181
|
-
export const pulse_provider = new PulseProvider()
|
|
@@ -2,86 +2,20 @@
|
|
|
2
2
|
* @vitest-environment jsdom
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
provider = new PulseProvider()
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('is disabled by default', () => {
|
|
20
|
-
const result = provider.capture('test', 'app')
|
|
21
|
-
expect(result).toBeUndefined()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('captures events after being enabled and initialized', () => {
|
|
25
|
-
provider.setEnabled(true)
|
|
26
|
-
provider.init()
|
|
27
|
-
|
|
28
|
-
// Should not throw
|
|
29
|
-
expect(() => {
|
|
30
|
-
provider.capture('user_login', 'test_app', { user: 'test@example.com' })
|
|
31
|
-
}).not.toThrow()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('stops capturing events when disabled', () => {
|
|
35
|
-
provider.setEnabled(true)
|
|
36
|
-
provider.init()
|
|
37
|
-
provider.capture('event1', 'app')
|
|
38
|
-
|
|
39
|
-
provider.setEnabled(false)
|
|
40
|
-
|
|
41
|
-
// Should not throw
|
|
42
|
-
expect(() => {
|
|
43
|
-
provider.capture('event2', 'app')
|
|
44
|
-
}).not.toThrow()
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { loadPulseClient, fetchBootConfig } from './pulse'
|
|
7
|
+
|
|
8
|
+
// The queue / transport / capture logic lives in the canonical pulse client,
|
|
9
|
+
// loaded at runtime from pulse's CDN (version-independent of the frappe backend).
|
|
10
|
+
// frappe-ui's only job is to load it and its config and degrade gracefully when
|
|
11
|
+
// neither is available — which is exactly the case in the test environment, where
|
|
12
|
+
// the remote asset URL and the backend method can't be resolved.
|
|
13
|
+
describe('telemetry plugin', () => {
|
|
14
|
+
it('degrades to null when the client asset cannot be loaded', async () => {
|
|
15
|
+
expect(await loadPulseClient()).toBeNull()
|
|
45
16
|
})
|
|
46
17
|
|
|
47
|
-
it('
|
|
48
|
-
|
|
49
|
-
const mockCall = call as any
|
|
50
|
-
|
|
51
|
-
provider.setEnabled(true)
|
|
52
|
-
provider.init()
|
|
53
|
-
|
|
54
|
-
const testEvents = [
|
|
55
|
-
{
|
|
56
|
-
event_name: 'test_event',
|
|
57
|
-
app: 'test_app',
|
|
58
|
-
captured_at: new Date().toISOString(),
|
|
59
|
-
},
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
await provider['sendEvents'](testEvents)
|
|
63
|
-
|
|
64
|
-
expect(mockCall).toHaveBeenCalledWith(
|
|
65
|
-
'frappe.utils.telemetry.pulse.client.bulk_capture',
|
|
66
|
-
{ events: testEvents },
|
|
67
|
-
)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('batches events before sending', () => {
|
|
71
|
-
vi.useFakeTimers()
|
|
72
|
-
|
|
73
|
-
provider.setEnabled(true)
|
|
74
|
-
provider.init()
|
|
75
|
-
|
|
76
|
-
// Capture multiple events
|
|
77
|
-
for (let i = 0; i < 5; i++) {
|
|
78
|
-
provider.capture('event', 'app', { count: i })
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Events should be queued, not sent immediately
|
|
82
|
-
const buffered = provider['eq']?.getBufferedEvents()
|
|
83
|
-
expect(buffered?.length).toBeGreaterThan(0)
|
|
84
|
-
|
|
85
|
-
vi.useRealTimers()
|
|
18
|
+
it('degrades to {} when the backend config is unreachable (e.g. old framework)', async () => {
|
|
19
|
+
expect(await fetchBootConfig()).toEqual({})
|
|
86
20
|
})
|
|
87
21
|
})
|
package/package.json
CHANGED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { call } from 'frappe-ui'
|
|
2
|
-
|
|
3
|
-
export const silentCall = <T>(method: string): Promise<T> => {
|
|
4
|
-
// To prevent console errors/logs from being shown
|
|
5
|
-
// when method doesn't exist in older versions of Frappe
|
|
6
|
-
const originalError = console.error
|
|
7
|
-
const originalLog = console.log
|
|
8
|
-
console.error = () => {}
|
|
9
|
-
console.log = () => {}
|
|
10
|
-
|
|
11
|
-
return call(method).catch(() => {}).finally(() => {
|
|
12
|
-
console.log = originalLog
|
|
13
|
-
console.error = originalError
|
|
14
|
-
})
|
|
15
|
-
}
|