A Vue Plugin That Reconstructs User Sessions Without the RUM Tax
How to Implement Full-Link User Behavior Monitoring in a Vue Project? A Detailed Lightweight Plugin Solution
When online users report that "the button didn't respond after clicking," and the backend interface logs are completely blank — this scenario gives every frontend developer a headache. Without screenshots or reproduction steps, you can only guess: was it a duplicate click? Did the route jump too early? Was it blocked by a popup? Or did the interface silently time out?
Many teams choose to scatter tracking points within business components, but as the project expands, the tracking code quickly becomes a historical burden that no one dares to delete. A more reasonable approach is to encapsulate a Vue global plugin, mount it once at the entry point, and uniformly collect user behavior, exceptions, and interface status, so that troubleshooting has evidence to follow.
1. What Data Should We Collect?
A practical frontend behavior monitor should cover:
- Route changes: Page entry/exit and stay trajectory
- User interactions: Click events, with clear identifiers for critical operations
- JS exceptions: Runtime errors + uncaught Promise rejections
- Interface monitoring: Requests taking too long or responding abnormally
- User's last N steps (Breadcrumb): Much more useful than verbal descriptions during troubleshooting
There's no need to introduce a heavy RUM SDK right from the start; writing a lightweight plugin yourself is completely sufficient.
2. Core Implementation of the Monitoring Plugin
// user-behavior-monitor.js
export function createBehaviorMonitor(options = {}) {
const queue = []
const maxCache = options.maxCache || 30
const endpoint = options.endpoint || '/api/client/track'
let currentPath = ''
// Session-level tracking ID
function getTraceId() {
let id = sessionStorage.getItem('__trace_id__')
if (!id) {
id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
sessionStorage.setItem('__trace_id__', id)
}
return id
}
// Enqueue; report immediately on error
function push(event) {
queue.push({
traceId: getTraceId(),
page: currentPath || location.pathname,
ts: Date.now(),
ua: navigator.userAgent,
...event
})
if (queue.length > maxCache) queue.shift()
if (event.level === 'error') flush('error')
}
// Report: prefer sendBeacon, fallback to fetch(keepalive)
function flush(reason = 'normal') {
if (!queue.length) return
const body = JSON.stringify({ reason, events: queue.splice(0, queue.length) })
if (navigator.sendBeacon) {
navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))
return
}
fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true
}).catch(() => {})
}
// Walk up from clicked element to find data-track or extract innerText
function resolveClickName(el) {
let node = el, depth = 0
while (node && depth < 4) {
if (node.dataset?.track) return node.dataset.track
if (node.innerText?.trim()) return node.innerText.trim().slice(0, 40)
node = node.parentElement
depth++
}
return el.tagName
}
return {
install(app, { router } = {}) {
app.config.globalProperties.$track = push
// Global click collection
document.addEventListener('click', e => {
if (!e.target) return
push({
type: 'click',
target: resolveClickName(e.target),
x: e.clientX,
y: e.clientY
})
}, true)
// JS errors
window.addEventListener('error', e => {
push({
type: 'js_error', level: 'error',
msg: e.message, file: e.filename,
line: e.lineno, col: e.colno
})
})
// Uncaught Promise rejections
window.addEventListener('unhandledrejection', e => {
push({
type: 'promise_error', level: 'error',
msg: String(e.reason?.message ?? e.reason)
})
})
// Flush on page close
window.addEventListener('beforeunload', () => flush('leave'))
// Vue Router tracking
if (router) {
router.beforeEach((to, from, next) => {
push({ type: 'route_leave', from: from.fullPath, to: to.fullPath })
currentPath = to.fullPath
next()
})
router.afterEach(to => {
push({ type: 'route_enter', path: to.fullPath, title: document.title })
})
}
// Periodic batch reporting
setInterval(() => flush('timer'), options.interval || 8000)
}
}
}
3. Mounting in main.js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createBehaviorMonitor } from './user-behavior-monitor'
const app = createApp(App)
app.use(router)
app.use(
createBehaviorMonitor({
endpoint: '/track/front',
maxCache: 40,
interval: 10000
}),
{ router }
)
app.mount('#app')
4. Explicitly Declaring Tracking Identifiers for Key Buttons
Automatically collecting innerText is sufficient for generic buttons, but operations like "Delete" or "Refund" within table rows must have data-track added; otherwise, you only know "Delete was clicked" without knowing which record was deleted:
<button data-track="order.pay.submit" @click="pay">Pay Now</button>
<button :data-track="`order.refund.${orderId}`" @click="refund">Refund</button>
Rule: Generic interactions can be auto-collected; core business flows must be explicitly named.
5. Integrating Axios to Monitor Slow and Erroneous Requests
// request.js
import axios from 'axios'
export function createTrackedHttp(app) {
const http = axios.create({ timeout: 12000 })
http.interceptors.request.use(cfg => {
cfg.metadata = { start: Date.now() }
return cfg
})
http.interceptors.response.use(
res => {
const cost = Date.now() - res.config.metadata.start
if (cost > 3000) {
app.config.globalProperties.$track({
type: 'api_slow',
url: res.config.url,
method: res.config.method,
cost
})
}
return res
},
err => {
const cfg = err.config || {}
const cost = cfg.metadata ? Date.now() - cfg.metadata.start : -1
app.config.globalProperties.$track({
type: 'api_error', level: 'error',
url: cfg.url, method: cfg.method, cost,
status: err.response?.status,
msg: err.message
})
return Promise.reject(err)
}
)
return http
}
When the backend receives click + api_error logs with the same traceId, it can fully reconstruct:
User enters
/order/submit→ clicksorder.pay.submit→ calls/api/pay/createwhich times out with 504 → took 12s
The frontend doesn't have to take the blame; the backend can use the traceId to correlate across gateway and application logs for investigation.
6. Several Production Deployment Considerations
- Do not report input field values: At most, record the field name (e.g.,
field: 'phone'); never collect user input content to avoid privacy compliance risks. - Page whitelist: Only monitor core flow pages (e.g.,
/order,/pay); skip low-frequency admin pages to reduce noise. - Batch + immediate error reporting: Normally, queue up and send periodically; flush immediately on error or beforeunload, balancing performance and completeness.
- Avoid excessive click reporting: Sample high-frequency areas to prevent tracking requests from back-pressuring page performance, especially in mobile weak-network environments.
Good user behavior monitoring's core value is not "full tracking coverage," but the ability to reconstruct the user's operation scene — what they clicked, where they navigated, where the interface was slow, where the error broke. If it can do that, it's a troubleshooting weapon, not just pretty log garbage.
Won't Recording Every Click Be Too Frequent?
"Pushing a record to the queue on every click" is indeed quite a lot, especially on large form pages, mobile weak networks, or list pages with frantic clicking, generating a large volume of low-value logs. Production environments typically apply several layers of "frequency reduction."
Below are several common, low-cost control methods that can be combined based on project sensitivity.
✅ Method 1: Sampling in High-Frequency Areas (Most Recommended)
For non-core interactions, randomly or proportionally discard:
// Inside the click listener
if (Math.random() > (options.sampleRate ?? 0.3)) return // Only collect 30%
Clicks in general browsing/scroll areas → sampled
Key buttons (withdata-trackpresent) → always collected
document.addEventListener('click', e => {
const hasTrack = !!e.target.closest('[data-track]')
if (!hasTrack && Math.random() > 0.3) return
push({ type: 'click', target: resolveClickName(e.target) })
}, true)
✅ Preserves troubleshooting value
❌ Won't overwhelm the backend
✅ Method 2: Debounce Same Target (Avoid Burst Clicks)
Multiple clicks on the same button within a short time are only recorded once:
let lastClickKey = ''
let lastClickTime = 0
document.addEventListener('click', e => {
const key = resolveClickName(e.target)
const now = Date.now()
if (key === lastClickKey && now - lastClickTime < 800) return
lastClickKey = key
lastClickTime = now
push({ type: 'click', target: key })
}, true)
Suitable for:
- Submit buttons
- Pagination switches
- Tab switches
✅ Method 3: Only Collect Clicks with Key Identifiers (Minimalist Approach)
If you only care about the business flow, you can completely abandon auto-collecting innerText:
document.addEventListener('click', e => {
const el = e.target.closest('[data-track]')
if (!el) return
push({
type: 'click',
track: el.dataset.track
})
}, true)
✅ Very few logs, clear semantics
❌ Cannot reconstruct "where the user randomly clicked"
Many internal admin systems eventually reach this stage.
✅ Method 4: Page-Level Whitelist (Control Scope)
Only enable click collection on key flow pages:
const enablePages = ['/order', '/pay', '/checkout']
if (!enablePages.some(p => location.pathname.startsWith(p))) return
Avoid:
- Admin dashboard list pages
- Long-scroll configuration pages
📌 Recommended Combination (Common in Practice)
| Scenario | Strategy |
|---|---|
| Core buttons | data-track + always collect |
| Generic clicks | Sample 20~30% |
| Same button | 800ms debounce |
| Pages | Only enable on core flows |
| Errors / Slow APIs / JS Errors | 100% immediate reporting |
One-Line Summary
It's not "record once per click," but rather: generic clicks can be discarded or sampled; key operations must be recorded; anomalies must be recorded.