自建埋点数据可视化系统-从前端到后台

前期做了埋点后可进行丰富的可视化实现,用来监控日常的运营情况。网上所谓的自动化埋点也并非全自动化的,而且要引入一套第三方的代友,你的用户数据都被发送到了第三方的服务器,非常的不放心,所以自建了一套埋点系统,数据库采用MySQL关系统型数据库,前端VUE+Elements UI,后台采用Spring Boot+MyBatis实现,并非针对Web应用封装了埋点的API,供别人调用,之所以没有采用自动化的埋点方式,是因为不想保存太多乱七八糟的数据,影响分析。

步骤标题
1指标设计
2数据库设计
3Java后台开发
4Vue+Webpack前端开发
5客户端埋点API开发

•ั一、指标设计

首先进行的是指标设计,就是将来要出什么样的统计分析图。常见的图表有下面这些。
1、APP平均访问量对比
所有应用平均访问量对比(周期:累计、近三月,近一月,近一周,当天)———柱状图
说明:因为每个应用对应的用户数是不同的,统计绝对的PV值做对比没有多大的意义,所以定义平均访问量=总访问量/用户人数

1、PV:
A、某应用PV趋势 (按周、按天)———折线图
B、某应用中各页面访问量对比(周期:近一月、近一周)———柱状图
C、某应用中各页面访问量趋势(按周、按天)

2、、UV访问量趋势
某用户访问了一次应用,计数+1,同一天重复访问此应用,仍记为访问1次。(按天,按周,折线趋势图)

3、用户存留率
留存率=新增用户中登录用户数/新增用户数*100%,用户留存率对于互联网应用很重要,但是对于BI系统来说,用户不需要注册(全员可用),所以不存在新增用户一说,但是用户流失这种现象依然是存在的,比如某应用上线后用户逐渐不再使用了,我们就要及时采取措施,为此可把用户留存率指标进行改造,以某个周期(如周)的开始为起点,记录此时访问的用户,统计这些用户后续时间的访问流失情况,这样也是有意义的。
A、按周:以周(7天)为单位,假设第一天有100人访问,第二天这100中只有80人访问,则第二天的留存率为80%,第三天这80人中只有20再次访问,则留存率降为20%,以此类推。
B、按月:上月新增用户数在本月访问了应用的个数 / 上个月新增用户数。改造为:上个月访问的用户数在本月依然访问的用户数 / 上个月访问的用户数

4、单应用用户活跃度
A、日活跃用户数(DAU-Daily Activated Users):某APP日活跃用户数 (此处等于日UV)
B、月活跃用户数(MAU-Month Activated Users):某APP月活跃用户数
C、用户活跃度(UAUser activity)=本周期内使用APP的用户数/本APP用户总量
周期:天、月、年
各APP日活、周活、月活度对比;(折线图)

5、页面停留时长对比
A、针对某个APP,用户停留时长的页面TOP 5 (按周、按月),可针对某个页面出趋势图
B、可对所有APP统计用户合计时长/用户数(平均用户访问时长)做对比,分析哪个APP用户粘性更大

6、跳出率分析
A、所有APP跳出率对比:用户进入应用就退出的量/总访问量 (按周、按月)
B、某应用中各页面的跳出率:用户进入此页面就退出的量/此页面的总访问量 (按周、按月)

7、页面用户操作统计
某页面中用户操作Top 5 (按周、按月)

8、单用户分析(针对某应用)
A、某用户活跃性分析(按天、按周、按月,此用户PV趋势折线图)
B、某用户关注点分析(用户常用操作,常看页面,停留时间最长的几个页面)(周期:累计)

9、用户设备统计
A、统计出用户手机机型持有情况(帕累托图:80%访问量的机型)
B、统计IOS和android机型占比

10、各分公司使用情况分析
A、针对某APP,分析33家分公司是否有用户覆盖,各分公司用户量(柱状图),对无用户覆盖的分公司重点关注
B、某分公司使用(对某APP的访问量)趋势图(按天、按周、按月),对访问量整体偏小或下降趋势的分公司重点关注

11、消息推送转化率
A、转化率=点击数/推送数 。实现方式:在推送时增加URL参数ATN,埋点load时带上ATN参数,记录在页面加载表里,每推送一条消息就在消息推送日志表中记录下此APP的消息,(统计页面加载表中带ATN参数的记录(去重-同用户同ATN值多次访问记为一次)/消息推送日志表中的记录数)就得到转化率了

•ั二、数据库设计

❣配置表

配置表
假如你有很多个项目或应用,配置表保存各个应用,名称,所在平台,只有在配置表里的应用才会被记录访问数据,将来也可以统计不同应用的访问情况。

❣页面访问日志表

页面访问日志
页面访问日志记录什么人在什么时间通过什么设备访问了哪个页面,并且停留了多长时间。也就是说用户访问过哪些页面。

❣页面操作日志表

页面操作日志
页面操作日志表记录用户在什么时间在页面中做了什么操作,比如点击了一个按钮,或者设置了什么条件等,这种数据是对用户行为和路径进行分析的基础。

•ั三、Java后台开发

项目结构图
使用Spring Boot搭建项目,全注解的方式,除了MyBatis的xml文件,没有多余的配置文件,通过IDea配置项目环境,设置启动器本地调试,前后端分离,Java层只负责Rest接口的输出。
配置项目属性
使用Maven进行打包,把构建好的项目部署到Tomcat下,前端构建的代码发到tomcat里,就可以运行了,不存在跨域问题。但在开发阶段,因为Java和Vue项目是分开启动的,端口不一样,存在跨域,需要配置前端的代理访问。

•ั四、Vue+Webpack前端

前端项目结构
细节不用多说,首先配置项目的编译和代理

❣项目配置

配置代理
这样前端就不存在跨域的问题了,当然代理也可以通过Nginx做,如果发布时Java项目和前端项目不在一起,就比较适合用Nginx做代理,开发时用webpack的代理就行了。

❣引入Elements UI相关的组件

为了减小项目压力,采用部分引入的方式,用不到的组件不引入。

import Vue from 'vue'
import App from './App'
import router from './router'
// import 'bootstrap/dist/js/bootstrap.js'
import 'font-awesome/css/font-awesome.min.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import axios from 'axios'
Vue.prototype.$http = axios
import echarts from 'echarts'
Vue.prototype.$echarts = echarts //引入组件

import { DatePicker, TimePicker,Select,OptionGroup, Option,Menu, Submenu, MenuItem, MenuItemGroup,Switch,Dialog} from 'element-ui'; Vue.use(DatePicker)
Vue.use(TimePicker)
Vue.use(Menu)
Vue.use(Submenu)
Vue.use(MenuItem)
Vue.use(MenuItemGroup)
Vue.use(TimePicker)
Vue.use(Switch)
Vue.use(Dialog)
Vue.use(Select)
Vue.use(OptionGroup)
Vue.use(Option)

Vue.config.productionTip = false
Date.prototype.toLocaleString=function(b){
	var m=this.getMonth()+1;
	if(m<10){m="0"+m;}
	if(b){
		return this.getFullYear()+"-"+m+"-"+this.getDate();
	}
	return this.getFullYear()+"-"+m+"-"+this.getDate()+" "+this.getHours()+":"+this.getMinutes()+":"+this.getSeconds();
}
/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})

在App.vue中处理一些路由相关的信息

<template>
  <div class="app">
    <router-view/>
  </div>
</template>

新建Home.vue用来做目录:

<template>
	<div class="home">
		 <div class="banner">
	      <img class="logo" src="./assets/logo.png">
	      元数据管理系统
	      <div class="pull-right banner_tools">
	      	{{user.user_name}}  <a href="javascript:void(0)" @click="logout()" class="fa fa-sign-out" title="退出"></a>
	      	<a href="/help" title="帮助" target="_blank"><i class="fa fa-question-circle-o"></i></a>
	      	<a href="/#/users/" title="用户管理" class="users"><i class="fa fa-users"></i></a>
	      </div>
	    </div>
	    <div class="south">
	      <div class="west">
	        <el-menu
	          :default-active="activeIndex"
	          class="el-menu-vertical-demo"
	          @open="handleOpen"
	          @close="handleClose">
	          
	          <el-submenu index="7">
	            <template slot="title">
	              <i class="fa fa-history blue"></i> 埋点管理
	            </template>
	            <el-menu-item-group>
	              <template slot="title">日志</template>
	              <el-menu-item index="7-1">
	                <a href="/#/bury/pagevisits"><i class="fa fa-history"></i> 页面访问日志</a>
	              </el-menu-item>
	              <el-menu-item index="7-2">
	                <a href="/#/bury/useroplogs"><i class="fa fa-history"></i> 用户操作日志</a>
	              </el-menu-item>
	            </el-menu-item-group>
	            <el-menu-item-group>
	              <template slot="title">报表</template>
	              <el-menu-item index="7-3">
	                <a href="/#/bury/appvisits"><i class="fa fa-bar-chart"></i> APP访问量</a>
	              </el-menu-item>
	              <el-menu-item index="7-4">
	                <a href="/#/bury/pvuv"><i class="fa fa-bar-chart"></i> PV-UV趋势</a>
	              </el-menu-item>
	              <el-menu-item index="7-6">
	                <a href="/#/bury/retention"><i class="fa fa-industry"></i> 用户存留率</a>
	              </el-menu-item>
	              <el-menu-item index="7-7">
	                <a href="/#/bury/activity"><i class="fa fa-bar-chart"></i> 用户活跃度</a>
	              </el-menu-item>
	              <el-menu-item index="7-8">
	                <a href="/#/bury/pagestats"><i class="fa fa-bar-chart"></i> 页面分析</a>
	              </el-menu-item>
	              <!-- <el-menu-item index="7-11">
	                <a href=""><i class="fa fa-bar-chart"></i> 单用户行为分析</a>
	              </el-menu-item>
	              <el-menu-item index="7-12">
	                <a href=""><i class="fa fa-bar-chart"></i> 用户设备统计</a>
	              </el-menu-item>
	              <el-menu-item index="7-13">
	                <a href=""><i class="fa fa-bar-chart"></i> 各分公司使用情况</a>
	              </el-menu-item>
	              <el-menu-item index="7-14">
	                <a href=""><i class="fa fa-bar-chart"></i> 消息推送转化率</a>
	              </el-menu-item> -->
	            </el-menu-item-group>
	          </el-submenu>
	          <el-menu-item index="8">
	            <a href="/#/analysis/explore"><i class="fa fa-shield purple"></i> 数据探索</a>
	          </el-menu-item>
	        </el-menu>
	      </div>
	      <div class="east">
	        <router-view/>
	      </div>
	    </div>
	</div>
</template>

❣页面访问日志查询页面

<template>
	<div class="criterions">
    <div  class="bread">
        <ul><li>元数据管理</li><li>埋点</li><li>页面访问日志</li></ul>
        <div class="btn-tools">
          应用:
          <el-select v-model="app" placeholder="请选择APP" size="mini">
            <el-option-group v-for="group in apps" :key="group.groupId" :label="group.groupName">
              <el-option v-for="item in group.apps" :key="item.appId"
                :label="item.appName" :value="item.appId">
              </el-option>
            </el-option-group>
          </el-select>
          页面:<input type="text" class="bread-input" v-model="pageId">
          用户:<input type="text" class="bread-sm-input" v-model="userId">
          <el-date-picker
            v-model="sedate"
            type="daterange"
            range-separator="至"
            size="small" 
            class="el-range-editor-bread"
            :editable="false" 
            start-placeholder="开始日期"
            end-placeholder="结束日期">
          </el-date-picker>
          <a href="javascript:void(0);" class="btn btn-sm btn-primary" @click="doSearch"><i class="fa fa-search"></i> 查询</a>
        </div>
    </div>
     
		<table class="table table-bordered table-striped">
			<thead>
				<tr>
            <th class="text-left">page_ID</th>
  					<th>页面名称</th>
            <th class="text-left">app_ID</th>
            <th>应用名称</th>
            <th class="text-left">platform_ID</th>
  					<th>系统名称</th>
  					<th>访问时间</th>
  					<th>用户</th>
  					<th>停留时长</th>
            <th>客户端</th>
				</tr>
			</thead>
			<tbody>
				<tr v-for="row in logs">
          <td>{{row.pageId}}</td>
					<td>{{row.pageName}}</td>
          <td>{{row.appId}}</td>
          <td>{{row.appName}}</td>
          <td>{{row.platformId}}</td>
					<td>{{row.platformName}}</td>
					<td>{{new Date(row.createTime).toLocaleString()}}</td>
					<td class="text-center">{{row.userId}}</td>
					<td class="text-center">{{row.stayTime}}</td>
					<td>{{formatter(row.userAgent)}}</td>
				</tr>
			</tbody>
		</table>
        <template>
            <v-Pagination  :total="total" :current-page='current' @pagechange="pagechange"></v-Pagination>
        </template>
	</div>
</template>
<script>
import Pagination from '@/components/Pagination'
export default {
  data () {
  	return {
    		logs: [],
        app:null,
        apps:[],
        sedate:[],
        pageId:null,
        userId:null,
        total: 0,     // 记录总条数
        current: 1  // 当前的页数
    	}
	},
	mounted(){
    this.getAPPs();
    this.fetchData();
	},
  components: {
    'v-Pagination': Pagination,
  },
	methods:{
    getAPPs(){
      var self=this;
      return this.$http.get('/api/buryData/getapps')
        .then(function (response) {
          if(response.data.rstCode==200){
            var groups=[];var list=response.data.data;
            for(var i=0;i<list.length;i++){
              if(groups.some(item=>item.groupId==list[i].platformId)){
                //添加进去
                for(var k=0;k<groups.length;k++){
                  if(groups[k].groupId==list[i].platformId){
                    groups[k].apps.push(list[i]);//加到第组里
                  }
                }
              }else{
                groups.push({groupId:list[i].platformId, groupName:list[i].platformName, apps:[list[i]] });
              }
            }
            self.apps=groups;
          }else{
            console.error(response.data);
          }
        }).catch(function (error) {console.log(error); });
    },
    fetchData: function (page=1) {
        var self = this
        var params={page:page,rows:20};
        if(self.userId){
          params.userId=self.userId;
        }
        if(self.pageId){
          params.pageId=self.pageId;
        }
        if(self.app){
          params.appId=self.app;
        }
        if(self.sedate && self.sedate.length==2){
          params.sDate=self.sedate[0].getTime();
          params.eDate=self.sedate[1].getTime();
        }
        return this.$http.get('/api/buryData/pagelogs',{params:params})
          .then(function (response) {
            if(response.data.rstCode==200){
                self.logs=response.data.data.list;
                self.total=response.data.data.total;
            }else{
                console.error(response.data);
            }
          }).catch(function (error) {
            console.log(error);
          });
    },
    pagechange:function(page){
       this.fetchData(page);
    },
    doSearch:function(){
      console.log(this.sedate);
      this.fetchData(1);
    },
    formatter:function(UA){
      var ua = UA.toLowerCase(); 
      var Sys = {}; 
      var s; 
      (s = ua.match(/msie ([\d.]+)/)) ? Sys.ie = "IE:"+s[1] :  0; 
      (s = ua.match(/wow64; trident\/([\d.]+)/)) ? Sys.Edge = "Edge Trident:"+s[1] :  0; 
      (s = ua.match(/firefox\/([\d.]+)/)) ? Sys.firefox = "Firefox:"+s[1] :  0; 
      (s = ua.match(/chrome\/([\d.]+)/)) ? Sys.chrome = "Chrome:"+s[1] :  0; 
      (s = ua.match(/opera.([\d.]+)/)) ? Sys.opera = "Opera:"+s[1] :  0; 
      (s = ua.match(/version\/([\d.]+).*safari/)) ? Sys.safari = "Safari:"+s[1] : 0; 
      (s = ua.match(/iphone os\s([\d+_]+).*mobile/)) ? Sys.iPhone = "iPhone OS:"+s[1] : 0; 
      (s = ua.match(/android\s([\d+.]+).*;(.*)\sbuild\/.*mobile/)) ? Sys.Android = "Android:"+s[1]+" "+(s[2]?s[2].toUpperCase():"") : 0; 
      return Sys.ie ||Sys.Edge||Sys.iPhone ||Sys.Android|| Sys.firefox ||Sys.chrome ||Sys.opera ||Sys.safari || UA;
    }
  }
}
</script>
<style scoped>
  .bread .btn-tools{flex:8;}
</style>

基中v-Pagination是自己开发的一个基于Bootstrap分页组件,因为ElementUI的分页组件太不好用了。实际上它的表格组件也不好用,我也自己做了一个,功能很强大,只是此处表格太简单,没有引入。
完成的效果截图(部分):
效果截图

•ั五、客户端埋点API-Javascript版

客户端项目想要埋点总不能都去自己写调接口的方法呀,所以在客户端我做了两套API,一个是纯JS版,不依赖任何组件库,另一个是VUE,为了篇幅简化,这里给出VUE版的部分代码供参考。
思路是:在页面加载时调用load接口,记录这次访问的信息,包括:页面、用户、终端、时间等,load接口会返回一个ID,把这个ID保存在前端,当用户退出此页面时,再调用一个stay接口,记录用户在此页面停留了多长时间,用来做跳出率分析。这里的难点在于,用户退出的事件捕获是很难的,因为用户可能是正常的关闭浏览器的标签页或浏览器窗口,也可能直接杀浏览器的进程,甚至直接关机走人,这时候你根本没机会调用stay接口,何况在Android和ios设备中更复杂,杀进程也更频繁,所以为了更可靠的记录用户在页面的停留时间,不得已采用定时器timer来做,每隔一秒调下stay接口,当然,更优的方法可以采用websocket方式,这个要Java端配置做相关的服务,而且要做心跳检测,自动重联,本项目暂时不这么搞。
至于用户行为,做个track方法记录一下就行了,客户端只用引入组件并调用相应的方法就可以了,对于VUE项目,不用在每个调用的VIEW里都去引入webbury组件,只用在路由处理的地方记录就可以了,在路由切换时可以根据load接口返回的id,记录下from页面的停留时间,这样一来timer甚至可以不用,但是尽量还是要用timer,以防记录不到停留时间的情况。

下面是埋点API的代码:

function param(obj) {
    var query = '';
    var name, value, fullSubName, subName, subValue, innerObj, i;

    for (name in obj) {
        value = obj[name];

        if (value instanceof Array) {
            for (i = 0; i < value.length; ++i) {
                subValue = value[i];
                fullSubName = name + '[' + i + ']';
                innerObj = {};
                innerObj[fullSubName] = subValue;
                query += param(innerObj) + '&';
            }
        } else if (value instanceof Object) {
            for (subName in value) {
                subValue = value[subName];
                fullSubName = name + '[' + subName + ']';
                innerObj = {};
                innerObj[fullSubName] = subValue;
                query += param(innerObj) + '&';
            }
        } else if (value !== undefined && value !== null) {
            query += encodeURIComponent(name) + '='
                + encodeURIComponent(value) + '&';
        }
    }
    return query.length ? query.substr(0, query.length - 1) : query;
}
function _extend(src,target){
    for(var k in target){
    	src[k]=target[k];
    }
    return src;
}

function WebBury(config={}){
	var store={};//记录每个页面的loadId和loadTime
	// var loadId=0;
	// var TSTART=0;
	var DEFO=_extend({
        platformId:'BI',
        userAgent:window.navigator.userAgent,
    },config);

    return {
    	auth(userId){
    		DEFO.userId=userId;
    	},
    	load(appId,pageId,pageName,mId){
    		//加载一个Page
            var p=_extend(DEFO,{appId,pageId,pageName,mId:mId});
            if(p.userId==null){console.error('userId is not ready for bury.');return;}
    		fetch(url+"PL?"+param(p)).then(res=>{
    			res.json().then(ret=>{
    				if(ret.data && ret.data.length>0){
    					store[pageId]={loadId:ret.data[0].id,loadTime:Date.now()};
    				}else{
                        console.error("fail on web bury.")
                    }
    			});
    			// {"data":[{"id":"f84d39f26987403b809b08fe3501aaec"}],"status":true}
    		});
    	},
    	track(appId,pageId,operation){
    		//记录一个操作
    		fetch(url+"UO?"+param(_extend(DEFO,{appId,pageId,operation}))).then(res=>{
    			res.json().then(ret=>{
    				// if(ret && ret.ret){}else{console.log(ret);}
    			});
    		});
    	},
    	stay(appId,pageId){
    		//找到store中的记录
    		if(store[pageId]){
    			var diff =parseInt((Date.now()-store[pageId].loadTime)/1000);
	    		fetch(url+"UST?"+param(_extend(DEFO,{appId,pageId,id:store[pageId].loadId,stayTime:diff}))).then(res=>{
	    			res.json().then(ret=>{
	    				if(ret && ret.ret){}else{console.log(ret);}
	    			});
	    		});
    		}
    	}
    }
}
export default WebBury;

总结

以上项目从设计,到数据库,Java,再到前端一气呵成,所用技术也都是主流,项目不难,但是要做好也挺麻烦,编辑器的配置、项目的配置,每一项都需要花时间,不过得益于SpringBoot的强大能力和Vue组件代开发的魅力,调试起来却很轻松。

  • 4
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值