跪拜 Guibai
← Back to the summary

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:

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 → clicks order.pay.submit → calls /api/pay/create which 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


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 (with data-track present) → 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:


✅ 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:


📌 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.