elevator-link
v0.8.0
Published
对齐后端 elevator 通用埋点接口的前端 SDK
Maintainers
Readme
elevator-link
对接 elevator 通用埋点接口的前端采集 SDK。页面浏览、停留时长、元素点击、热区曝光、站外跳转全自动采集,业务侧只需声明两个 DOM 属性。
包内不含任何域名、白名单与密钥,baseUrl 一律由接入方传入。采集范围由服务端配置下发,加减埋点不需要前端发版。
安装
npm i elevator-link快速开始
import { Track } from 'elevator-link'
// 放在首屏渲染完成之后调用
void Track.init({
baseUrl: 'https://elevator.example.com',
bizLine: 'myoffer',
bizEntityAlias: 'hsbc-hk',
})不必 await,也不必 try/catch。参数缺失、接口拒绝、网络失败都会被内部收敛成「打一行告警 + 不采集」,不会向业务抛错,也不会阻塞页面。
启动时机要放在首屏 DOM 就绪之后。 init 的最后一步会读页面根节点的 data-track-page 作为 pageId,DOM 还空着就启动会读到 null,首屏的元素采集不会注册。用 vue-router 的话等 router.isReady():
void router.isReady().then(() => {
app.mount('#app')
Track.init({ /* ... */ })
})声明采集对象
页面根节点标 pageId,需要采集的元素标 trackId:
<div data-track-page="OfferHome">
<button data-track-id="btn-activate" data-track-props='{"offerId":"OF20260901"}'>
立即启用
</button>
<div data-track-id="home-banner">首页横幅</div>
</div>data-track-page 和 data-track-id 的值必须出现在后端 /track/client/init 下发的配置里,SDK 才会采集。页面上挂了属性但配置里没有的元素不会产生任何事件 —— 采集范围由服务端配置控制,前端加属性不用等后端、后端改配置不用前端发版。
data-track-props 是可选的业务扩展属性,JSON 字符串,原样透传进事件的 props。上限 2KB,超限时整个 props 被替换为 { propsTruncated: true }。
采集的事件
| 事件 | 触发时机 | durationMs |
|---|---|---|
| site_enter | 一次访问的首个页面 | 无 |
| page_view | 页面结束时(路由切换 / 卸载 / 切后台) | 本页可见停留时长 |
| click | 点击当场 | 无 |
| area_view | 页面结束时,一个热区一条 | 该热区累计可见时长 |
| site_leave | 经由本站跳转到外部站点 | 无 |
时长统计的都是可见时长:切到后台的那段不计入。热区以可见面积 ≥50% 为准,累计不足 1 秒不产生事件。
环境字段
每条事件都带 deviceType(Mobile / PC)、os、browser、ua。前三个是 SDK 解析 UA 的结果,os 与 browser 认不出时为空;ua 是 navigator.userAgent 原串,不裁剪不清洗,供后端做二次解析或排查设备问题。四个字段在 Track.init 时解析一次,之后每条事件复用。
navigator 不可用(SSR、某些 WebView)时四个字段全为空,事件照常上报。
热区被点过会带 props.areaClick = true
这一段时间里落点在热区范围内发生过点击,那条 area_view 就带 props: { areaClick: true },没点过不带这个键(空即未点,不发 false)。不产出额外的 click 事件,所以任何点击量口径都不受影响。
判定只看落点在不在热区的 DOM 范围内 —— 不要求落点自己声明 data-track-id,也不要求它配了 click。问的是「这片区域有没有被点」,不是「哪个按钮被点了」。标记和 durationMs 一样是增量语义,每次结算后清零,所以拆段后的下一段要重新点过才会再带。
areaClick 不在服务端的提升键列表里,落在 props JSON 列。要按它筛选又不想拆 JSON 的话,需要后端把它加进提升键列表、加一个表列。元素自己在 data-track-props 里写的键不受影响,两者并存。
page_view 与 area_view 的 durationMs 是增量语义 —— 同一页面若因切后台等原因多次结算,各条独立,相加得到总时长。
上下文变化会拆分时长
page_view 与 area_view 都在页面结束才结算,而用户可能中途切了语种、登录、登出或换了卡。所以 Track.setLang()、Track.setUser()、Track.clearUser() 都会先把当前页已累计的停留与曝光按旧上下文结算上报,再以新上下文重新起段。
不拆的话一条事件要横跨两种上下文,只能记其中一边:切语种后整段停留挂新语种;token 过期登出后,登录态下那段浏览被记成匿名(uid / cid 为空)。
「繁中下看 20 秒 → 切成英文 → 又看 30 秒 → 刷新」得到:
| 事件 | lang | durationMs |
|---|---|---|
| page_view | zn_HK | 20 秒 |
| area_view | zn_HK | 该热区在繁中下的曝光 |
| page_view | en_HK | 30 秒 |
| area_view | en_HK | 该热区在英文下的曝光 |
前两条在切换那一刻发出,后两条在刷新时发出。durationMs 是增量语义,同一页面各条相加仍等于总停留。
登录态同理:「匿名浏览 20 秒 → 登录 → 又看 30 秒」得到两条 page_view,前一条 uid / cid 为空,后一条带身份。只换 sessionToken 不拆段(不影响事件归属);用相同的 uid / cid 重复调 setUser() 也不拆段,业务可以放心用 watch(..., { immediate: true }) 同步身份。
拆出来的续段不足 1 秒不单独成条,与热区「划过不算」同一把尺。业务改身份的写法基本都是「先更新身份、紧接着跳转」—— 登录成功后 setUser() 再 router.push,退出登录 / 改密成功后 clearUser() 再回登录页。中间只隔几十到几百毫秒,页面其实已经在离开了,不拦的话每次登录、每次登出都会多一条几乎零时长的记录。身份真的在浏览途中变化时(token 过期那种)续段是长的,照常成条。
切成相同语种同样不产生任何事件。热区的上下文快照会跟着更新,所以原地保留(没被重渲染换掉)的热区在切换后那一段也带新值。
click 这类即时事件不受影响,本来就是发生当下取值。
报表侧注意事件条数变化 —— 同一页面可能有多条 page_view,按 (visit_id, page_id) 去重算浏览量、按 SUM 算停留。
空闲超时 15 分钟
连续 15 分钟没有任何用户操作,就认为访客已经离开:结算上报一次当前页的 page_view 和各热区 area_view,然后停止计时、不再续。用户开着标签页走人,这一页的数据会在 15 分钟后落库,而不是永远不上报、或者等浏览器关闭时产出一条十几小时的记录。
停留时长记到最后一次操作为止,空闲那段不算。 用户第 2 分钟停止操作、第 17 分钟被判定离开,上报的 durationMs 是 2 分钟 —— 事件在第 17 分钟发出,但时长不含中间那 15 分钟。
这不是「时长上限」。 任何操作都会把计时器推后,所以一直在用的页面停留时长可以正常超过 15 分钟 —— 每 10 分钟操作一次连用 40 分钟,得到的是一条 40 分钟的 page_view,不会被切成多条。
算作操作的事件:click、keydown、scroll、mousemove、touchstart、wheel,外加「从后台切回本标签页」。scroll 和 mousemove 必须算在内,否则读长文的用户会被误判成已离开。全部以 passive 监听注册,处理函数只写一个时间戳。
全程没有任何操作的页面会记 durationMs: 0,这条 page_view 照发 —— 它表达「页面打开过但没有任何互动」,是有用的跳出信号。duration 本来就是增量语义,报表按 (visit_id, page_id) 去重算浏览量、按 SUM 算停留,0 值对两者都没有影响。
超时上报后该页面不再产生停留数据,之后切前后台、切路由都不会补发。但用户若回来继续点,click 事件照常上报 —— 点击不依赖页面计时。
页面在后台时不做空闲判定:切后台那一刻已经结算过停留了,而且「切到别的标签页」本身说明不了用户是否离开,等切回来重新起算。判定是 10 秒一次轮询,实际触发比 15 分钟最多晚 10 秒。
离开本站(site_leave)
site_leave 表示访客经由本站跳转到了外部站点,目标地址记在 props.content(完整 URL),检测来源记在 props.leaveReason。
自动检测三类,不需要给元素加 data-track-id,也不受下发配置约束:
| 方式 | leaveReason |
|---|---|
| 点击 <a href="站外">(含 target="_blank") | anchor |
| <form action="站外"> 提交 | form |
| window.open('站外') | window_open |
location.href = url 拦不住,需要业务显式通知:
Track.leaveSite(url) // leaveReason: 'explicit_call'
location.href = url原因是 HTML 规范把 Location 的 href、assign、replace 标记为 [LegacyUnforgeable],不可重定义也不可覆盖,SDK 无法包装。顺序要先调 leaveSite 再赋值 —— 反了页面已经在卸载,请求可能来不及发出。
站外的判定按 origin 严格比较(协议 + 域名 + 端口)。以下都不算离开:同源链接、javascript: / mailto: / tel: 等非 http(s) 协议、纯 # 锚点、空 href、带 download 属性的下载链接。
target="_blank" 算离开,但当前页面不会卸载,所以访客可以连续点多个外链,每次各记一条。
刷新、关闭标签、切到后台都不产生 site_leave —— 访客并没有离开本站。这些时刻只结算 page_view 和 area_view。因此本 SDK 不提供「整次访问时长」这个字段,需要的话由数仓按 visit_id 聚合 page_view.duration_ms 得出(它是增量语义,可以直接 SUM)。
富文本与动态内容
运营后台注入的富文本(v-html / innerHTML)里的外链无需任何埋点属性即可检测,因为 click 走的是 document 上的事件委托,动态插入的节点天然覆盖,不需要注册。深层嵌套、协议相对写法(//host/path)、<svg> 里的 <a> 都能正确取到目标。
链接目标读的是 HTMLAnchorElement.href(浏览器解析后的绝对地址)而非原始属性,所以页面存在 <base href> 时不会漏报 —— 读原始属性会把 base 指向站外的相对链接判成同源。<form action> 同理。
iframe 内的外链检测不到。 iframe 是独立文档,点击不冒泡到父文档,同源也一样。富文本用 iframe 渲染时(部分编辑器的预览模式)里面的跳转全部采集不到,要支持得在 iframe 内部单独初始化一次 SDK,跨域则无解。
API
Track.init(options) // 启动采集,幂等
Track.setUser({ uid, cid, sessionToken }) // 登录成功后调用
Track.clearUser() // 登出
Track.setLang('zh-HK') // 站点语言切换
Track.setEntity('boc-hk') // 切业务主体(传别名),会重新拉配置并重建监听
Track.setChannel('kol_abc') // 渠道来源
Track.leaveSite(url) // location.href 跳转前主动通知业务主体(别名换标识)
bizEntityAlias 传的是别名,事件里上报的 bizEntity 是服务端按别名换出来的标识,两者可以不同值。SDK 不提供直接设置 bizEntity 的入口 —— 目的是让同一个主体在数仓里只有一种口径,否则各接入方传 hsbc / HSBC / hsbc-hk 会裂成三个主体。
别名未登记时服务端把 bizEntity 回落为别名原文并返回空 pages:页面浏览、停留、进入离开照常采集,元素采集不启动。接入方不需要为这种情况写分支。
setEntity(alias) 切主体时会请求 /track/client/config 换配置。请求返回前沿用上一份 bizEntity,失败则主体与配置整体保持原样,不会出现空主体的中间态。
sessionToken 传明文,SDK 取其 SHA-256 前 32 位作为 sessionId 上报,明文不出网。
登录前已上报的事件不会被追溯改写,分析时靠 clientId / visitId 串联登录前后的行为,所以不必为了等登录而延后 init。
init 之外的方法都是纯本地状态写入(setEntity 例外,会发一次配置请求),可以安全地重复调用。
InitOptions
| 字段 | 必填 | 说明 |
|---|---|---|
| baseUrl | ✅ | elevator 服务地址,不含路径 |
| bizLine | ✅ | 业务线标识,需在后端注册 |
| bizEntityAlias | | 业务主体别名,由研发在服务端登记;未传时按空串匹配。真正入库的 bizEntity 由服务端换出后下发,SDK 不接受直接指定 |
| lang | | 显式语种,优先级高于 URL 参数与 navigator.language |
| channel | | 显式渠道,未传时从 URL 的 channel / utm_source 取 |
接入约定
data-track-page 挂在每个页面自己的根节点上。 SDK 取 document.querySelector('[data-track-page]') 的第一个命中元素,挂在 <RouterView> 外层容器会让所有页面共用同一个 pageId。
data-track-id 在同一页面内要唯一。 热区结算按元素逐条产出,同一 trackId 挂在 N 个同时存在的元素上会得到 N 条 area_view,trackId 相同、无法区分是哪一个被看到。click 没有这个问题。
元素因重渲染被替换是另一回事:旧节点在移除时停止计时、新节点从零开始,两段各自成条 —— 这是有意的,因为它们可能发生在不同的语种下(见下)。报表要该热区在本页的总曝光时,按 (visit_id, page_id, track_id) 求和。
异步路由组件、异步路由守卫都支持(0.6.1 起)。SDK 在 history.pushState 之后用 setTimeout(..., 0) 读 data-track-page,那一刻 DOM 可能还是上一个页面的(浏览器后退 + 守卫里有网络请求最容易撞上)。所以 SDK 还盯着 data-track-page 的属性变化,值变了就校正当前段的页面归属并按新配置重新注册热区,不产出额外事件。
埋点元素允许嵌套。 点击时沿祖先链往上找第一个「配置里监听了 click」的元素,所以卡片埋点套按钮埋点的场景下,点没配置的按钮会正确归到外层卡片。一次点击只上报一条,不向上重复。
运行环境
需要 IntersectionObserver(Safari ≥ 12.1)。不支持时热区采集整体降级关闭,其余采集不受影响。
sendBeacon、localStorage、sessionStorage、crypto.randomUUID 缺失时均有降级路径。sessionStorage 不可用(如 Safari 无痕)时 visitId 无法跨页面延续,每次页面加载会成为一次新访问。
已知边界
访客通过地址栏输入、书签、或直接关闭标签离开时,SDK 无法感知目标,不产生 site_leave —— 这是刻意的,那些情况并非「经由本站跳转」。
iframe 内的点击采集不到(见上文富文本一节)。
计时使用 Date.now(),它不是单调时钟。页面长时间停留期间若发生系统校时,durationMs 可能出现跳变。
变更记录
0.8.0
- 每条事件新增
ua字段,值是navigator.userAgent原串(不裁剪、不清洗),与原有的deviceType/os/browser并存。后端需要先加ua列,否则这个字段会被丢弃
0.7.1
- 热区点击标记的键由
content(字符串'true')改为areaClick(布尔true)。0.7.0 请直接跳过 —— 两者是同一个功能的两种写法,content那版不再支持
0.7.0
- 热区在本段时间内被点过时,
area_view带点击标记(没点过不带这个键)。不产出额外的click事件,点击量口径不受影响
0.6.2
- 修复登录 / 退出登录 / 改密成功后,所在页面多出一条几乎零时长的
page_view(同一个page_id两条,一条带uid一条不带)。这些流程都是先更新身份再跳转,resegment拆出来的续段只有几十到几百毫秒。现在续段不足 1 秒不单独成条,与热区用同一个门槛
0.6.1
- 修复浏览器后退回到某个页面后,该页面的
area_view一条都不上报、page_view的pageId还是上一页的。成因是 URL 在 popstate 之前就变了,而框架要等异步路由守卫跑完才渲染,SDK 读data-track-page时读到的还是上一页的值。现在data-track-page变化会校正当前段的页面归属并按新配置重新注册热区(不产出额外事件)
0.6.0
Track.setUser()/Track.clearUser()在uid或cid实际变化时也会结算当前页并重新起段。修的是「登录态浏览一阵、token 过期登出后,那段浏览的uid/cid被记成空」—— 登录 / 登出 / 切卡前后的停留与曝光现在各自成条。报表侧注意事件条数变化- 只更新
sessionToken、或用相同uid/cid重复调用,都不产生额外事件
0.5.0
Track.setLang()现在会结算当前页并以新语种重新起段:切语种前后的停留与曝光各自成条、时长独立统计,page_view与area_view都按语种拆分。报表侧注意事件条数变化 —— 同一页面可能有多条page_view,按(visit_id, page_id)去重算浏览量、按 SUM 算停留- 热区的语种快照随
setLang更新,原地保留的热区在切换后那一段带新语种
0.4.2
- 修复
page_view/area_view的lang取值时机:改为记录该段时长发生时的语种,此前取的是上报时刻的值。这两种事件都在页面结束才结算,用户中途切过语种的话,发生在旧语种下的停留与曝光会被打上新语种的标签 - 撤销 0.4.1 的热区按 trackId 合并:重渲染换掉节点产生的多段曝光分属不同上下文(语种就不同),各自成条才对。报表要总曝光时按
(visit_id, page_id, track_id)求和
0.4.1
- 结算后清理已脱离文档且时长已结算的热区条目,避免频繁重渲染的页面上内部 Map 无限增长
- (本版引入的「热区按 trackId 合并」已在 0.4.2 撤销,勿使用本版)
0.4.0
破坏性变更:业务主体改为「别名换标识」。 接入方传别名,真正入库的 bizEntity 由服务端换出后下发。
Track.init({ bizEntity })→Track.init({ bizEntityAlias })Track.setEntity(bizEntity)→Track.setEntity(bizEntityAlias)- 事件的
bizEntity一律取init/config响应值,不再等于传入的入参;SDK 不提供直接设置它的入口 - 别名未登记时服务端把
bizEntity回落为别名原文并返回空pages,页面级行为照常采集,SDK 无额外分支 setEntity请求失败时主体与配置整体保持原样,不会出现空主体的中间态
升级要点: 接入方原来传的主体标识,需由研发在服务端 track_biz_entity.biz_entity_alias 登记后才生效。别名与 biz_entity 登记为同值时,接入方只改字段名即可,无其他调整。
0.3.0
- 新增空闲超时:连续 15 分钟无操作即认为访客离开,结算上报当前页的
page_view/area_view后停止计时,不再续。有操作会把计时推后,正常使用的页面停留时长仍可超过 15 分钟 - 空闲结算的时长记到最后一次操作为止,不含空闲那段(第 2 分钟停手、第 17 分钟上报,记 2 分钟)
- 路由切换的结算加上空批判断,不再可能落
duration=0的page_view(空闲超时那条除外,它有意义)
0.2.0
破坏性变更:site_leave 语义完全改变。 原先由页面卸载与切后台触发、携带整次访问累计时长;现在只在经由本站跳转到外部站点时产生、不携带 durationMs,目标地址落在 props.content。
site_leave新增props.content(目标完整 URL)与props.leaveReason(anchor/form/window_open/explicit_call)- 新增
Track.leaveSite(url),用于location.href这类无法拦截的跳转 - 移除
props.visitDurationPartial(随durationMs一起失去意义) - 刷新、关标签、切后台改为只结算
page_view与area_view - 需要「整次访问时长」的话,改由数仓按
visit_id聚合page_view.duration_ms
同版本修复:
- 首屏热区未被
IntersectionObserver观察,导致首屏area_view恒为空 - 跨路由存活的热区(固定侧栏、
keep-alive复用)从第二个页面起不再统计 - 切后台再切回后,
page_view会把已上报的时长重复计入 - 点击「嵌套在已配置元素内的未配置元素」时,外层的
click被吞掉 body超过 64KB 时未按体积拆批,在卸载期通道上会整批发不出去init失败时仍写入访问状态,导致服务恢复后site_enter永久不再上报<base href>存在时,指向站外的相对链接被漏判为同源
0.1.0
首个版本。
License
MIT
