背景
互聯網發展到現在,數據的重要性已經不需要再多的強調,那如何做好數據搜集的工作則是每一家公司都要面臨的問題。尤其是像天貓、京東、寺庫這樣的電商公司,數據的統計可以提升用戶購買的用戶體驗,可以方便運營和產品調整銷售策略等等。可見頁面埋點多么重要。今天就讓我們從無到有制作一個埋點上報工具。
主要內容:
- 什么是埋點
- 埋點原理
- 埋點的種類
- 電商頁面前端埋點規范
- 封裝一個異步請求
- IntersectionObserver -新一代元素觀察接口
- 基于VUE從零開始封裝一個前端數據埋點工具
- 后端日志格式(前端理解即可)
- 后端nginx配置(前端理解即可)
- 需要改進的地方
什么是埋點
所謂“埋點”,是數據采集領域(尤其是用戶行為數據采集領域)的術語,指的是針對特定用戶行為或事件進行捕獲、處理和發送的相關技術及其實施過程。比如用戶某個icon點擊次數、觀看某個視頻的時長等等。
埋點原理分析和流程概述
簡單來說,網站統計分析工具需要收集到用戶瀏覽目標網站的行為(如打開某網頁、點擊某按鈕、將商品加入購物車等)及行為附加數據(如某下單行為產生的訂單金額等)。早期的網站統計往往只收集一種用戶行為:頁面的打開。而后用戶在頁面中的行為均無法收集。這種收集策略能滿足基本的流量分析、來源分析、內容分析及訪客屬性等常用分析視角,但是,隨著ajax技術的廣泛使用及電子商務網站對于電子商務目標的統計分析的需求越來越強烈,這種傳統的收集策略已經顯得力不能及。
后來,Google在其產品谷歌分析中創新性的引入了可定制的數據收集腳本,用戶通過谷歌分析定義好的可擴展接口,只需編寫少量的javascript代碼就可以實現自定義事件和自定義指標的跟蹤和分析。目前百度統計、搜狗分析等產品均照搬了谷歌分析的模式。
其實說起來兩種數據收集模式的基本原理和流程是一致的,只是后一種通過javascript收集到了更多的信息。下面看一下現在各種網站統計工具的數據收集基本原理。
首先,用戶的行為會---這里姑且先認為行為就是打開網頁。當網頁被打開,頁面中的埋點javascript片段會被執行,一般網站統計工具都會要求用戶在網頁中加入一小段javascript代碼,這個代碼片段一般會動態創建一個script標簽,并將src指向一個單獨的js文件,例子中為dot.js.此時這個單獨的js文件(圖中綠色節點)會被瀏覽器請求到并執行,這個js往往就是真正的數據收集腳本。數據收集完成后,js會請求一個后端的數據收集腳本(圖中的backend),這個腳本一般是一個偽裝成圖片的動態腳本程序,可能由php、python或其它服務端語言編寫,js會將收集到的數據通過http參數的方式傳遞給后端腳本,后端腳本解析參數并按固定格式記錄到訪問日志。
上面是一個數據收集的大概流程,下面以寺庫商城為例,對每一個階段進行一個相對詳細的分析。
1.1 埋點腳本執行階段
技術棧為vue,當頁面中的資源加載完成后,執行埋點的腳本,如下圖
1.2 數據收集腳本執行階段
數據收集腳本(dot.js)被請求后當頁面展示會被執行,這個腳本一般要做如下幾件事:
(1)通過瀏覽器內置javascript對象收集頁面基本信息,如頁面title(通過document.title)、url(頁面鏈接)、用戶顯示器分辨率(通過windows.screen)、cookie信息(通過document.cookie)等等一些信息。
(2)收集曝光樓層信息。
(3)將上面兩步收集的數據進行拼接。
(4)請求一個后端腳本,將信息放在http request參數中攜帶給后端腳本。
這里唯一的問題是步驟4,javascript請求后端腳本常用的方法是ajax,但是ajax是不能跨域請求的。這里dot.js在被統計網站的域內執行,而后端腳本在另外的域,ajax行不通。一種通用的方法是js腳本創建一個Image對象(log.gif),將Image對象的src屬性指向后端腳本并攜帶參數,此時即實現了跨域請求后端。這也是后端腳本為什么通常偽裝成gif文件的原因。通過http抓包可以看到dot.js對log.gif的請求:
1.3 后端腳本執行階段
log.gif是一個偽裝成gif的腳本。這種后端腳本一般要完成以下幾件事情:
(1)解析http請求參數的到信息。
(2)從服務器(WebServer)中獲取一些客戶端無法獲取的信息,如訪客ip等。
(3)將信息按格式寫入log。
(4)生成一副1×1的空gif圖片作為響應內容并將響應頭的Content-type設為image/gif。
(5)在響應頭中通過Set-cookie設置一些需要的cookie信息。
之所以要設置cookie是因為如果要跟蹤唯一訪客,通常做法是如果在請求時發現客戶端沒有指定的跟蹤cookie,則根據規則生成一個全局唯一的cookie并種植給用戶,否則Set-cookie中放置獲取到的跟蹤cookie以保持同一用戶cookie不變(見圖4)。
埋點的種類
業界的埋點方案主要分為以下三類:
代碼埋點
代碼埋點就是在需要數據統計的地方植入數據上報的代碼,統計用戶行為。
優點:可以非常精確的選擇什么時候發送數據。
缺點:維護代價較大,每一次更新都要對埋點代碼進行維護,否則大概率搜集不到舊版本的數據。
可視化埋點
利用可視化交互手段,數據產品/數據分析師可以通過可視化界面(管理后臺連接設備) 配置事件,如下是騰訊移動分析的可視化埋點界面。可視化埋點仍需要先配置相關事件,再采集。
- 優點:埋點只需業務同學接入,無需開發支持;
- 缺點:僅支持客戶端行為。
無埋點
無埋點是指開發人員集成采集 SDK 后,SDK 便直接開始捕捉和監測用戶在應用里的所有行為,并全部上報,不需要開發人員添加額外代碼。
數據分析師/數據產品 通過管理后臺的圈選功能來選出自己關注的用戶行為,并給出事件命名。之后就可以結合時間屬性、用戶屬性、事件進行分析了。所以無埋點并不是真的不用埋點了。
優點:
無需開發,業務人員埋點即可;
支持先上報數據,后進行埋點。
缺點:
數據量大;
僅僅支持客戶端。
無埋點和可視化埋點均不需要開發支持,僅數據業務同學進行設置即可。但兩者數據上報-埋點設置存在加大的差異:無埋點支持在數據上報之后再進行埋點設置,因而數據采集/上報的量遠大于可視化埋點。
因而無埋點的數據大都有清空機制,例如growingIO,允許版本發布后7天內設置埋點,超過7天數據清空,無法追溯。
這次主要講代碼埋點
代碼埋點分為 命令式埋點 與 聲明式埋點 :
命令式埋點,顧名思義,開發者需要手動在需要埋點的節點處進行埋點。如點擊按鈕或鏈接后的回調函數、頁面ready時進行請求的發送。大家肯定都很熟悉這樣的代碼:
// 頁面加載時發送埋點請求
$(document).ready(function(){
// ... 這里存在一些業務邏輯
sendRequest(params);
});
// 按鈕點擊時發送埋點請求
$('button').click(function(){
// ... 這里存在一些業務邏輯
sendRequest(params);
});
可以很容易發現,這樣的做法很有可能會將埋點代碼侵入業務代碼,這使整體業務代碼變得繁瑣,容易出錯,且后續代碼會愈加膨脹,難以維護。所以,我們需要讓埋點的代碼與具體的業務邏輯解耦,即 聲明式埋點 ,從而提高埋點的效率和代碼的可維護性。
聲明式埋點理論上,只需要關注兩個問題:
- 需要埋點的DOM節點;
- 所需攜帶的數據
因此,可以很快想出一個聲明式埋點的方法:
// key表示埋點的唯一標識;act表示埋點方式
<span v-clstag-dot = "{'act':'thumbs', 'key': 'details_product_1_dot_thumbs', 'productId': product.productId}">點贊</span>
稍后會詳細講聲明式埋點的實現原理
電商頁面前端埋點規范
建立一個好的規范非常重要,包括命名規范、上報規范、數據規范和使用規范*。
1.埋點命名規范
埋點名稱為上報日志中的key字段,第三條會講到,我們當前的做法是埋點名稱只能是由字母、數字、下劃線組成,并保證在應用內唯一。
常用規則的舉例如下:
比如行為埋點:{頁面名稱}+{組件名稱}+{組件id}+{功能}+{動作}
組件名稱和動作最為重要,它決定著后端收到埋點后要進行哪種操作,開發過程中要和后端嚴格制定好名稱,比如點擊的是商品列表。我們約定好了商品為product,那么組件名稱就必須為product.比如點擊了收藏,和后端約定好的是thumbs,動作就必須為:thumbs。另外,id從0開始。
組件名稱列表:
廣告:ad
商品:product
購物車:car
其他:可和后端協商
動作列表:
點擊:click
收藏:collection
評論:comment
點贊; thumbs
加入購物車: add
其他:可和后端協商
示例:
// key表示埋點的唯一標識;act表示埋點方式
<span v-clstag-dot = "{'act':'thumbs', 'key': 'details_product_1_dot_thumbs', 'productId': product.productId}">點贊</span>
埋點啟動日志和錯誤上報日志:{頁面名稱}+{頁面id}+{動作}
示例:
'key': 'details_123_show'
'key':'details_123_error'
2.埋點上報規范
(1)針對曝光埋點數據的上報策略一般如下:
基于時間間隔:每隔 n秒(時間間隔可以根據公司的業務情況自定義)
基于數據條數:每累積 n條數據(條數可以自定義)
不間斷實時上報,如果是低頻率,數據量小,實時性要求高的數據可以不設限制
為以防用戶卸載 App或者關閉瀏覽器造成本地數據的丟失,會將未上報的埋點存儲在localstorage,瀏覽器關閉埋點數據并不會被刪除,如果用戶再次訪問,會啟動上報。基于Native提供的bridge,讓Native幫忙持久化數據,并在再次進入時,啟動上報。這里也可以創建一個單獨的串行隊列,來實現對本地持久化數據的逐個上報。
(2)事件埋點和錯誤埋點的上報策略
- 事件發生后及時上報
-
數據規范
每個公司都有自己的埋點數據規范,里面匯總了需要上報的埋點數據,例如
image.png
我們要統計的數據埋點包含
- 使用規范
(1)引入埋點腳本一定要在頁面資源加載完,例如:
import { dot } from './assets/js/dot'
// 中央事件總線封裝
Vue.use(VueBus)
Vue.config.productionTip = false
/* eslint-disable no-new */
// Vue.directive() 這個方法寫在new Vue之前
dot.clickExpDot(Vue)
window.onload = function () {
dot.postError()
dot.dotPageReadyData()
dot.show()
}
new Vue({
el: '#app',
router,
store, // 使用store
components: { App },
template: '<App/>'
})
(2)聲明式埋點在html中引入的規范,例如:
#曝光埋點的用法
<div class="exposure-statistics" show-dot="{'act':'show',' key':'details_ad_1_flowtab_show'}">
#事件埋點的用法
<span v-clstag-dot = "{'act':'thumbs', 'key': 'details_product_1_dot_thumbs', 'productId': product.productId}">點贊</span>
封裝一個異步請求
1. axios(考慮到跨域問題,本次沒有使用)
在vue項目中,和后臺交互獲取數據這塊,我們通常使用的是axios庫,它是基于promise的http庫,可運行在瀏覽器端和node.js中。他有很多優秀的特性,例如攔截請求和響應、取消請求、轉換json、客戶端防御cSRF等。所以我們的尤大大也是果斷放棄了對其官方庫vue-resource的維護,直接推薦我們使用axios庫。如果還對axios不了解的,可以移步axios文檔。
安裝
npm install axios; // 安裝axios復制代碼
引入
一般我會在項目的src目錄中,新建一個request文件夾,然后在里面新建一個http.js和一個api.js文件。http.js文件用來封裝我們的axios,api.js用來統一管理我們的接口。
代碼如下:
import axios from 'axios'
// import QS from 'qs'
import { Toast } from 'vant'
// 環境的切換
if (process.env.NODE_ENV === 'development') {
axios.defaults.baseURL = 'http://localhost:8080'
} else if (process.env.NODE_ENV === 'production') {
axios.defaults.baseURL = 'http://localhost:8080'
}
// 請求超時時間
axios.defaults.timeout = 10000
// post請求頭
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
// 請求攔截器
axios.interceptors.request.use(config => {
// 請求處理
return config
}, err => {
// 處理請求錯誤
return Promise.reject(err)
})
// 響應攔截器
axios.interceptors.response.use(
response => {
if (response.status === 200) {
return Promise.resolve(response.data)
} else {
return Promise.reject(response)
}
},
// 服務器狀態碼不是200的情況
error => {
if (error.response.status) {
switch (error.response.status) {
case 500:
Toast({
message: '系統錯誤',
duration: 1000,
forbidClick: true
})
break
case 201:
Toast({
message: '業務失敗!',
duration: 1000,
forbidClick: true
})
break
// 其他錯誤,直接拋出錯誤提示
default:
Toast({
message: '失敗',
duration: 1500,
forbidClick: true
})
}
return Promise.reject(error.response)
}
}
)
/**
* get方法,對應get請求
* @param {String} url [請求的url地址]
* @param {Object} params [請求時攜帶的參數]
*/
export function get (url, params) {
return new Promise((resolve, reject) => {
axios.get(url, {
params: params
}).then(res => {
resolve(res)
}).catch(err => {
reject(err.data)
})
})
}
/**
* post方法,對應post請求
* @param {String} url [請求的url地址]
* @param {Object} params [請求時攜帶的參數]
*/
export function post (url, params) {
return new Promise((resolve, reject) => {
// axios.post(url, QS.stringify(params)).then(res => {
axios.post(url, params).then(res => {
resolve(res)
}).catch(err => {
reject(err.data)
})
})
}
使用方式
const getGroupId = params => post(commonApi.apiGetGroupId, params)
2. 用img發送請求
用img發送請求的方法英文術語叫:image beacon
主要應用于只需要向服務器發送日志數據的場合,且無需服務器有消息體回應。比如收集訪問者的統計信息。
這樣做和ajax請求的區別在于:
1.只能是get請求,因此可發送的數據量有限。
2.只關心數據是否發送到服務器,服務器不需要做出消息體響應。并且一般客戶端也不需要做出響應。
3.實現了跨域
使用圖片上報埋點是當下比較流行的一種方式,阿里,京東都在用。簡單的封裝如下:
// pageview是將上報的json格式的數據轉換成a=b&c=d格式的字符串
export default function analytics (action = 'pageview') {
(new Image()).src = `https://xxx/test_upload?action=${action}×tamp=${Date.now()}`
}
IntersectionObserver,新一代元素觀察接口
統計頁面區域曝光,需要判斷區域是否在視口中,這個時候就需要用到IntersectionObserver了。
概念
IntersectionObserver接口(從屬于Intersection Observer API)為開發者提供了一種可以異步監聽目標元素與其祖先或視窗(viewport)交叉狀態的手段。祖先元素與視窗(viewport)被稱為根(root)。
重點看這里監聽目標元素與其祖先或視窗交叉狀態的手段,其實就是觀察一個元素是否在視窗可見。
可以看到,交叉了就是說明當前元素在視窗里,當前就是可見的了。
API
var observer = new IntersectionObserver(callback, options)
其實就是一個簡單的構造函數。
以上代碼會返回一個IntersectionObserver
實例,callback
是當元素的可見性變化時候的回調函數,options
是一些配置項(可選)。
我們使用返回的這個實例來進行一些操作。
Observer.observe(document.querySelector('img')) 開始觀察,接受一個DOM節點對象
Observer.unobserve(element) 停止觀察 接受一個element元素
Observer.disconnect() 關閉觀察器
options
root
用于觀察的根元素,默認是瀏覽器的視口,也可以指定具體元素,指定元素的時候用于觀察的元素必須是指定元素的子元素
threshold
用來指定交叉比例,決定什么時候觸發回調函數,是一個數組,默認是[0]
。
const options = {
root: null,
threshold: [0, 0.5, 1]
}
var Observer = new IntersectionObserver(callback, options)
Observer.observe(document.querySelector('img'))
上面代碼,我們指定了交叉比例為0,0.5,1,當觀察元素img0%、50%、100%時候就會觸發回調函數
rootMargin
用來擴大或者縮小視窗的的大小,使用css的定義方法,10px 10px 30px 20px
表示top、right、bottom 和 left的值
const options = {
root: document.querySelector('.box'),
threshold: [0, 0.5, 1],
rootMargin: '30px 100px 20px'
}
為了方便理解,我畫了張圖,如下
首先我們來看下圖上的問題,藍線是什么呢?他就是咱們定義的root元素,我們添加了rootMargin
屬性,將視窗的增大了,虛線就是現在的視窗,所以元素現在也就在視窗里面了。
由此可見,root元素只有在rootMargin
為空的時候才是絕對的視窗。
說了簡單的options,接下來我們看下callback
。
callback
上面我們說到,當元素的可見性變化時,就會觸發callback函數。
callback函數會觸發兩次,元素進入視窗(開始可見時)和元素離開視窗(開始不可見時)都會觸發
var io = new IntersectionObserver((entries)=>{
console.log(entries)
})
io.observe($0)
以上代碼,請在chrome控制臺進行調試,這里我使用了$0
選擇了上一次我審查元素的選擇的節點
運行結果如下
我們可以看到callback函數有個entries
參數,它是個IntersectionObserverEntry
對象數組,接下來我們重點說下IntersectionObserverEntry對象
IntersectionObserverEntry
IntersectionObserverEntry
提供觀察元素的信息,有七個屬性。
boundingClientRect 目標元素的矩形信息
intersectionRatio 相交區域和目標元素的比例值 intersectionRect/boundingClientRect 不可見時小于等于0
intersectionRect 目標元素和視窗(根)相交的矩形信息 可以稱為相交區域
isIntersecting 目標元素當前是否可見 Boolean值 可見為true
rootBounds 根元素的矩形信息,沒有指定根元素就是當前視窗的矩形信息
target 觀察的目標元素
time 返回一個記錄從IntersectionObserver
的時間到交叉被觸發的時間的時間戳
上面幾個矩形信息的關系如下
?? 劃重點
intersectionRatio和isIntersecting是用來判斷元素是否可見的
使用IntersectionObserver編寫圖片懶加載
好了,通過上面一些概念我們大概了解了IntersectionObserver
是個什么東西,接下來我們用它來寫點代碼,寫什么呢?沒錯就是懶加載。
通過IntersectionObserver來實現懶加載,就簡單的多了,我們只需要設置回調,判斷當前元素是否可見,再進行渲染操作就行了,而不用去關心內部的計算。
主要代碼如下
const io = new IntersectionObserver(()=>{ // 實例化 默認基于當前視窗
})
let ings = document.querySelectorAll('[data-src]') // 將圖片的真實url設置為data-src src屬性為占位圖 元素可見時候替換src
function callback(entries){
entries.forEach((item) => { // 遍歷entries數組
if(item.isIntersecting){ // 當前元素可見
item.target.src = item.target.dataset.src // 替換src
io.unobserve(item.target) // 停止觀察當前元素 避免不可見時候再次調用callback函數
}
})
}
imgs.forEach((item)=>{ // io.observe接受一個DOM元素,添加多個監聽 使用forEach
io.observe(item)
})
基于VUE從零開始封裝一個前端數據埋點工具
到此。所有編寫埋點的準備知識就差不多了,接著用vue-cli創建一個vue項目。項目目錄結構簡化如下:
|-- build // 項目構建(webpack)相關代碼
| |-- build.js // 生產環境構建代碼
| |-- check-version.js // 檢查node、npm等版本
| |-- webpack.base.conf.js // webpack基礎配置
| |-- webpack.dev.conf.js // webpack開發環境配置
| |-- webpack.prod.conf.js // webpack生產環境配置
|-- config // 項目開發環境配置
| |-- dev.env.js // 開發環境變量
| |-- index.js // 項目一些配置變量
| |-- prod.env.js // 生產環境變量
| |-- test.env.js // 測試環境變量
|-- mySql // 使用node開發的接口,這次沒有使用
| |-- api.js // node開發的api
| |-- db.js // 數據庫連接配置
| |-- index.js // node開啟的服務
| |-- router.js // api封裝
| |-- sqlMap.js // api名稱
|-- src // 源碼目錄
| |-- api // axios封裝的接口請求
| |-- components // vue公共組件
| |-- page // vue頁面
| | |--dot.vue //用來展示埋點上報的詳情頁
| |-- assets // 公共資源
| | |--js //公共腳本
| | | |--dot.js //封裝的前端數據埋點工具
| |-- App.vue // 頁面入口文件
| |-- main.js // 程序入口文件,加載各種公共組件
|-- static // 靜態文件,比如一些圖片,json數據等
| |-- data // 群聊分析得到的數據用于數據可視化
|-- .babelrc // ES6語法編譯配置
|-- .editorconfig // 定義代碼格式
|-- .gitignore // git上傳需要忽略的文件格式
|-- README.md // 項目說明
|-- favicon.ico
|-- index.html // 入口頁面
|-- package.json // 項目基本信息
基于Vue指令的聲明式埋點
由于在埋點的需求中使用了Vue作為基礎框架,結合上面聲明式埋點的例子,很容易就聯想到 Vue自定義指令。Vue自定義指令提供了一種機制,將數據的變化映射為 DOM 行為。以 Vue 1.x 版本為例,自定義指令提供了幾個鉤子函數:
bind:只調用一次,在指令第一次綁定到元素上時調用。
update: 在 bind 之后立即以初始值為參數第一次調用,之后每當綁定值變化時調用,參數為新值與舊值
unbind:只調用一次,在指令從元素上解綁時調用
這樣的特性可以很好的解決以上的一些問題。我們只需要像這樣:
Vue.directive('stat', {
bind: function () {
// 準備工作
},
update: function (newValue, oldValue) {
// 值更新時的工作
// 也會以初始值為參數調用一次, 此時可以根據傳值類型來進行相應埋點行為的請求處理
},
unbind: function () {
// 清理工作
}
})
在一個Vue應用中,不需要再去遍歷DOM樹,因為在Vue應用中基本所有DOM操作都是使用數據的變更結合Vue的內置指令實現,Vue可以感知到這些變更。在指令從元素上解綁時我們也可以去銷毀已經綁定的事件。
在這個項目中我們給點擊,點贊,評論,收藏添加了聲明式埋點,我們只需要像下面一樣聲明就可以了:
<div v-clstag-dot = "{'act':'click', 'key': product.productId}"></div>
給vue添加內置指令的代碼:
/**
* @description: 點擊統計埋點(命令式))
* @param {obj} vue實例
*/
clickExpDot: function (Vue) {
let that = this
Vue.directive('clstag-dot', {
bind: function (el, binding, vnode) {
el.addEventListener('click', (e) => {
e.stopPropagation()
let time = {
timestamp: new Date().getTime()
}
let query = Object.assign({}, time, binding.value, that.params)
that.analytics(that.splicingStr(query))
}, false)
}
})
}
最后,需要在main.js中引入聲明式埋點,錯誤監控,上報pv,uv,區域展示埋點等就可以了
// dot.clickExpDot() 這個方法寫在new Vue之前
import { dot } from './assets/js/dot'
dot.clickExpDot(Vue)
// 頁面加載完成后執行錯誤監控,上報pv,uv,區域展示埋點等
window.onload = function () {
dot.postError()
dot.dotPageReadyData()
dot.show()
}
整個項目的完整源代碼在github上埋點工具
后端日志格式(前端同學理解就好,可不用實現)
日志格式主要考慮日志分隔符,一般會有以下幾種選擇:固定數量的字符、制表符分隔符、空格分隔符、其他一個或多個字符、特定的開始和結束文本。
我們在 nginx 的配置文件中定義日志格式:
log_format
"$msec||$remote_addr||$status||$body_bytes_sent||$u_domain||$u_url|
|$u_title||$u_referrer||$u_sh||$u_sw||$u_cd||$u_lang||$http_user_ag
ent||$u_account";
注意這里以 u_開頭的是我們待會會自己定義的變量,其它的是nginx內置變量
后端nginx配置(前端同學理解就好,可不用實現)
log.gif 是后端腳本,是一個偽裝成 gif 圖片的腳本。后端腳本一般需要完 成以下幾件事情:
1、解析 http 請求參數得到信息。
2、從 Web 服務器中獲取一些客戶端無法獲取的信息,如訪客 ip 等。
3、將信息按格式寫入 log。
4、生成一副 1×1 的空 gif 圖片作為響應內容并將響應頭的 Content-type設為 image/gif。
5、在響應頭中通過 Set-cookie 設置一些需要的 cookie 信息。
之所以要設置 cookie 是因為如果要跟蹤唯一訪客,通常做法是如果在請求 時發現客戶端沒有指定的跟蹤 cookie,則根據規則生成一個全局唯一的 cookie 并 種植給用戶,否則 Set-cookie 中放置獲取到的跟蹤 cookie 以保持同一用戶 cookie 不變。這種做法雖然不是完美的(例如用戶清掉 cookie 或更換瀏覽器會被認為是兩個用戶),但是目前被廣泛使用的手段。
我們使用 nginx 的 access_log 做日志收集,不過有個問題就是 nginx 配置本身的邏輯表達能力有限,所以選用 OpenResty 做這個事情。
番外:什么是OpenResty?
OpenResty是一個基于 Nginx 與 Lua 的高性能 Web 平臺,其內部集成了大量精良的 Lua 庫、第三方模塊以及大多數的依賴項。其中的核心是通過 ngx_lua 模塊集成了 Lua,從而在 nginx 配置文 件中可以通過 Lua 來表述業務。而Lua 是一種輕量小巧的腳本語言,用標準 C 語言編寫并以源代碼形式開放, 其設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能。
這里給出Nginx配置文件
worker_processes 2;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format user_log_format "$msec||$remote_addr||$status||$body_bytes_sent||$u_domain||$u_url||$u_title||$u_referrer||$u_sh||$u_sw||$u_cd||$u_lang||$http_user_agent||$u_account";
sendfile on; #允許sendfile方式傳輸文件,默認為off
keepalive_timeout 65; #連接超時時間,默認為75s
server {
listen 80;
server_name localhost;
location /log.gif {
#偽裝成gif文件
default_type image/gif;
#nginx本身記錄的access_log,日志格式為main
access_log logs/access.log main;
access_by_lua "
-- 用戶跟蹤cookie名為__utrace
local uid = ngx.var.cookie___utrace
if not uid then
-- 如果沒有則生成一個跟蹤cookie,算法為md5(時間戳+IP+客戶端信息)
uid = ngx.md5(ngx.now() .. ngx.var.remote_addr .. ngx.var.http_user_agent)
end
ngx.header['Set-Cookie'] = {'__utrace=' .. uid .. '; path=/'}
if ngx.var.arg_domain then
-- 通過subrequest到/i-log記錄日志,將參數和用戶跟蹤cookie帶過去
ngx.location.capture('/i-log?' .. ngx.var.args .. '&utrace=' .. uid)
end
";
#此請求資源本地不緩存
add_header Expires "Fri, 01 Jan 1980 00:00:00 GMT";
add_header Pragma "no-cache";
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
#返回一個1×1的空gif圖片
empty_gif;
}
location /i-log {
#內部location,不允許外部直接訪問
internal;
#設置變量,注意需要unescape
set_unescape_uri $u_domain $arg_domain;
set_unescape_uri $u_url $arg_url;
set_unescape_uri $u_title $arg_title;
set_unescape_uri $u_referrer $arg_referrer;
set_unescape_uri $u_sh $arg_sh;
set_unescape_uri $u_sw $arg_sw;
set_unescape_uri $u_cd $arg_cd;
set_unescape_uri $u_lang $arg_lang;
set_unescape_uri $u_account $arg_account;
#打開subrequest(子請求)日志
log_subrequest on;
#自定義采集的日志,記錄數據到user_defined.log
access_log logs/user_defined.log user_log_format;
#輸出空字符串
echo '';
}
}
}
整個項目的后端部署可參考:網站用戶行為日志采集和后臺日志服務器搭建 此處不做過多的解釋,搭建教程網上有很多,可根據自己的實際情況部署。
打開network可以看到攜帶的參數
為了方便觀察,將生成的日志保存為txt文件,以日期加log.text的格式命名:
項目需要改進的地方
無網絡延時上報
思考一個問題,假如我們的頁面處于斷網離線狀態(比如就是信號不好),用戶在這期間進行了操作,而我們又想收集這部分數據會怎樣?
假如斷網非常短暫,腳本持續執行并且未觸發打包上傳。由于log仍保留在內存中,繼續執行直到觸發可上傳數量后,網絡已恢復,此時無影響。
斷網時間較長,中間觸發幾次上報,網絡錯誤會導致上報失敗。之后恢復網絡,后續日志正常上報,此時丟失了斷網期間數據。
斷網從某一刻開始持續到用戶主動關閉頁面,期間日志均無法上報。
我們可以嘗試增加“失敗重傳”的功能,比起網絡不穩定,更多的情況是某個問題導致的穩定錯誤,重傳不能解決這類問題。設想我們在客戶端進行數據收集,我們可以很方便地記錄到log文件中,于是同樣的考慮,我們也可以把數據暫存到localstorage上面,有網環境下再繼續上報,因此解決這個問題的方案我們可以歸納為:
- 上報數據,navigator.onLine判斷網絡狀況
- 有網正常發送
- 無網絡時記入localstorage, 延時上報
更好的pv: visibilitychange
PV是日志上報中很重要的一環。
目前為止我們基本實現完上報了,現在再回歸到業務層面。pv的目的是什么,以及怎樣更好得達到我們的目的? 推薦先閱讀這篇關于pv的文章:
為什么說你的pv統計是錯的
在大多數情況下,我們的pv上報假設每次頁面瀏覽(Page View)對應一次頁面加載(Page Load),且每次頁面加載完成后都會運行一些統計代碼, 然而這情況對于尤其單頁應用存在一些問題
- 用戶打開頁面一次,而在接下來的幾天之內使用數百次,但是并沒有刷新頁面,這種情況應該只算一個 Page View 么
- 如果兩個用戶每天訪問頁面次數完全相同,但是其中一個每次刷新,而另一個保持頁面在后臺運行,這兩種使用模式的 Page View 統計結果應該有很大的不同么
為了遵循更好的PV,我們可以在腳本增加下列情況的處理:
- 頁面加載時,如果頁面的 visibilityState 是可見的,發送 Page View 統計;
- 頁面加載時, 如果頁面的 visibilityState 是隱藏的,就監聽 visibilitychange 事件,并在 visibilityState 變為可見時發送 Page View 統計;
- 如果 visibilityState 由隱藏變為可見,并且自上次用戶交互之后已經過了“足夠長”的時間,就發送新的 Page View 統計;
- 如果 URL 發生變化(僅限于 pathname 或 search 部分發送變化, hash 部分則應該忽略,因為它是用來標記頁面內跳轉的) 發送新的 Page View 統計
考慮到不同業務場景,我們還有有更多空間可以填補,數據閉環其實也是為了更好的業務分析服務,雖然是一個傳統功能,但值得細細考究的點還是挺多的吧