dsh-heatmap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 埋点事件契约(host 与 client 共用同一份,保证采集端与收集端字段一致)。
3
+ *
4
+ * 设计原则(科学的埋点体系):
5
+ * - schema 带版本号 `v`,后续字段演进可平滑迁移;
6
+ * - 每条事件都有稳定 `id` / `sessionId` / `ts`,可做漏斗与会话回放;
7
+ * - 只记录「元素身份 + 位置 + 时间 + 视口」,绝不采集输入内容或对话正文,
8
+ * 从根上满足隐私与合规(默认最小化采集)。
9
+ */
10
+
11
+ export const SCHEMA_VERSION = 1
12
+
13
+ /** 事件类型:覆盖页面生命周期、交互、滚动曝光、热力图坐标四类。 */
14
+ export type EventType =
15
+ | 'session_start' // 会话开始(页面加载)
16
+ | 'session_end' // 会话结束(卸载/隐藏)
17
+ | 'page_view' // 路由/hash 变化
18
+ | 'click' // 按钮/可点击元素点击(含坐标,供热力图)
19
+ | 'focus' // 获得焦点
20
+ | 'blur' // 失去焦点
21
+ | 'hover' // 鼠标悬停坐标(节流,供热力图)
22
+ | 'scroll_depth' // 滚动深度(0..1)
23
+ | 'visibility' // 页签可见性变化(用于停留时长)
24
+ | 'input' // 输入框输入(仅记录长度,不记录内容)
25
+ | 'impression' // 元素曝光(面板/控件首次进入视口 ≥50%)
26
+
27
+ /** 被操作元素的「身份指纹」,用于跨会话聚合到同一控件。 */
28
+ export interface ElementRef {
29
+ tag: string
30
+ id?: string
31
+ role?: string
32
+ ariaLabel?: string
33
+ title?: string
34
+ testId?: string
35
+ /** 表单控件名(input/textarea/select 的 name 属性,不含内容)。 */
36
+ name?: string
37
+ /** 表单占位符(placeholder,不含用户输入内容)。 */
38
+ placeholder?: string
39
+ /** 截断后的可见文本(仅限 UI 控件文案,如按钮/链接,最长 40 字;输入类控件不采集)。 */
40
+ text?: string
41
+ /** 稳定的元素标识(优先 data-testid > id > aria-label > role+tag > placeholder/name > tag+text)。 */
42
+ elementId: string
43
+ }
44
+
45
+ /** 单条埋点事件。 */
46
+ export interface AnalyticsEvent {
47
+ v: number
48
+ id: string
49
+ ts: number
50
+ sessionId: string
51
+ type: EventType
52
+ page: { path: string; hash: string }
53
+ target?: ElementRef
54
+ /** 视口内坐标(click / hover 供热力图)。 */
55
+ position?: { x: number; y: number }
56
+ /** 滚动深度 0..1(scroll_depth)。 */
57
+ depth?: number
58
+ /** 页签是否可见(visibility)。 */
59
+ visible?: boolean
60
+ /** 输入长度(input,仅数字,不含内容)。 */
61
+ inputLength?: number
62
+ }
63
+
64
+ /** 客户端上传给 host 收集器的一批事件。 */
65
+ export interface AnalyticsBatch {
66
+ v: number
67
+ sessionId: string
68
+ sentAt: number
69
+ events: AnalyticsEvent[]
70
+ }
71
+
72
+ /** host 侧聚合统计结果。 */
73
+ export interface StoreStats {
74
+ total: number
75
+ byType: Record<string, number>
76
+ sessions: number
77
+ clicks: number
78
+ focus: number
79
+ impressions: number
80
+ earliestTs?: number
81
+ latestTs?: number
82
+ }
83
+
84
+ /** 漏斗阶段:有序步骤的会话到达数与相对上一阶段的转化率。 */
85
+ export interface FunnelStage {
86
+ step: string
87
+ sessions: number
88
+ conversion: number
89
+ }
90
+
91
+ /** 漏斗分析结果。 */
92
+ export interface FunnelResult {
93
+ totalSessions: number
94
+ stages: FunnelStage[]
95
+ }
96
+
97
+ /** 单个会话的有序事件时间线(用于会话回放/复现)。 */
98
+ export interface SessionTimeline {
99
+ sessionId: string
100
+ startTs?: number
101
+ endTs?: number
102
+ events: AnalyticsEvent[]
103
+ }
package/src/store.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * host 侧埋点数据存储:内存环形缓冲 + NDJSON 落盘。
3
+ *
4
+ * NDJSON(一行一条 JSON)便于增量追加、流式读取,也能直接给后续统一分析
5
+ * 平台消费。内存缓冲用于快速聚合统计(stats / export),落盘用于持久化。
6
+ */
7
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
8
+ import { dirname, resolve } from 'node:path'
9
+ import type { AnalyticsBatch, AnalyticsEvent, FunnelResult, SessionTimeline, StoreStats } from './shared/types.ts'
10
+
11
+ export class AnalyticsStore {
12
+ private events: AnalyticsEvent[] = []
13
+ private readonly file: string
14
+ private readonly ready: Promise<void>
15
+ private readonly maxEvents: number
16
+
17
+ constructor(dataDir: string, maxEvents = 20000) {
18
+ this.maxEvents = maxEvents
19
+ this.file = resolve(dataDir, 'events.ndjson')
20
+ this.ready = this.init()
21
+ }
22
+
23
+ /** 数据文件绝对路径(供诊断/调试)。 */
24
+ get filePath(): string {
25
+ return this.file
26
+ }
27
+
28
+ private async init(): Promise<void> {
29
+ await mkdir(dirname(this.file), { recursive: true })
30
+ try {
31
+ const raw = await readFile(this.file, 'utf8')
32
+ this.events = raw
33
+ .split('\n')
34
+ .filter(line => line.trim() !== '')
35
+ .map(line => JSON.parse(line) as AnalyticsEvent)
36
+ if (this.events.length > this.maxEvents) {
37
+ this.events = this.events.slice(-this.maxEvents)
38
+ }
39
+ } catch {
40
+ this.events = []
41
+ }
42
+ }
43
+
44
+ /** 接收一批事件:追加进内存与文件,返回实际接收条数。 */
45
+ async ingest(batch: AnalyticsBatch): Promise<number> {
46
+ await this.ready
47
+ const accepted = Array.isArray(batch?.events) ? batch.events : []
48
+ if (accepted.length === 0) return 0
49
+ this.events.push(...accepted)
50
+ if (this.events.length > this.maxEvents) {
51
+ this.events = this.events.slice(-this.maxEvents)
52
+ }
53
+ const lines = accepted.map(e => JSON.stringify(e)).join('\n') + '\n'
54
+ await appendFile(this.file, lines, 'utf8')
55
+ return accepted.length
56
+ }
57
+
58
+ /** 导出事件(`limit <= 0` 表示全部)。 */
59
+ async list(limit = 0): Promise<AnalyticsEvent[]> {
60
+ await this.ready
61
+ if (limit > 0) return this.events.slice(-limit)
62
+ return this.events
63
+ }
64
+
65
+ /** 聚合统计。 */
66
+ async stats(): Promise<StoreStats> {
67
+ await this.ready
68
+ const byType: Record<string, number> = {}
69
+ const sessions = new Set<string>()
70
+ let clicks = 0
71
+ let focus = 0
72
+ let impressions = 0
73
+ let earliestTs: number | undefined
74
+ let latestTs: number | undefined
75
+ for (const e of this.events) {
76
+ byType[e.type] = (byType[e.type] ?? 0) + 1
77
+ sessions.add(e.sessionId)
78
+ if (e.type === 'click') clicks += 1
79
+ if (e.type === 'focus') focus += 1
80
+ if (e.type === 'impression') impressions += 1
81
+ if (earliestTs === undefined || e.ts < earliestTs) earliestTs = e.ts
82
+ if (latestTs === undefined || e.ts > latestTs) latestTs = e.ts
83
+ }
84
+ return { total: this.events.length, byType, sessions: sessions.size, clicks, focus, impressions, earliestTs, latestTs }
85
+ }
86
+
87
+ /** 按会话聚合的有序事件时间线(供回放/复现与漏斗分析共用)。 */
88
+ private async timelines(): Promise<Map<string, AnalyticsEvent[]>> {
89
+ await this.ready
90
+ const bySession = new Map<string, AnalyticsEvent[]>()
91
+ for (const e of this.events) {
92
+ const list = bySession.get(e.sessionId)
93
+ if (list === undefined) bySession.set(e.sessionId, [e])
94
+ else list.push(e)
95
+ }
96
+ for (const list of bySession.values()) list.sort((a, b) => a.ts - b.ts)
97
+ return bySession
98
+ }
99
+
100
+ /** 会话列表:每个会话的有序事件时间线。 */
101
+ async sessions(limit = 0): Promise<SessionTimeline[]> {
102
+ const bySession = await this.timelines()
103
+ const out: SessionTimeline[] = []
104
+ for (const [sessionId, events] of bySession) {
105
+ out.push({
106
+ sessionId,
107
+ startTs: events.length > 0 ? events[0].ts : undefined,
108
+ endTs: events.length > 0 ? events[events.length - 1].ts : undefined,
109
+ events,
110
+ })
111
+ }
112
+ out.sort((a, b) => (b.endTs ?? 0) - (a.endTs ?? 0))
113
+ return limit > 0 ? out.slice(0, limit) : out
114
+ }
115
+
116
+ /**
117
+ * 漏斗分析:给定有序事件类型步骤,统计有多少会话按顺序走到每一步,
118
+ * 并计算相邻步骤的转化率。
119
+ * @param steps - 有序事件类型(如 ['session_start', 'click', 'input'])。
120
+ */
121
+ async funnel(steps: string[]): Promise<FunnelResult> {
122
+ const bySession = await this.timelines()
123
+ const valid = (steps ?? []).filter((s): s is string => typeof s === 'string' && s !== '')
124
+ const counts = new Array<number>(valid.length).fill(0)
125
+ for (const events of bySession.values()) {
126
+ let stepIdx = 0
127
+ for (const e of events) {
128
+ if (stepIdx < valid.length && e.type === valid[stepIdx]) stepIdx += 1
129
+ }
130
+ for (let i = 0; i < stepIdx; i += 1) counts[i] += 1
131
+ }
132
+ const stages = valid.map((step, i) => ({
133
+ step,
134
+ sessions: counts[i],
135
+ conversion: i === 0 ? 1 : (counts[i - 1] > 0 ? counts[i] / counts[i - 1] : 0),
136
+ }))
137
+ return { totalSessions: bySession.size, stages }
138
+ }
139
+
140
+ /** 清空数据。 */
141
+ async clear(): Promise<void> {
142
+ await this.ready
143
+ this.events = []
144
+ await writeFile(this.file, '', 'utf8')
145
+ }
146
+ }