@bstage-sdk/analytics 0.0.0-stage → 0.9.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.
- package/LICENSE +21 -0
- package/README.md +236 -2
- package/dist/index.cjs +1534 -0
- package/dist/index.d.cts +391 -0
- package/dist/index.d.ts +391 -0
- package/dist/index.js +1461 -0
- package/package.json +38 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1534 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ATTR_MAX: () => ATTR_MAX,
|
|
24
|
+
BATCH_MAX: () => BATCH_MAX,
|
|
25
|
+
BID_NAME: () => BID_NAME,
|
|
26
|
+
BODY_MAX: () => BODY_MAX,
|
|
27
|
+
CLICK_ATTR: () => CLICK_ATTR,
|
|
28
|
+
DEPTHS: () => DEPTHS,
|
|
29
|
+
EVENT_ATTR: () => EVENT_ATTR,
|
|
30
|
+
EVENT_MAX: () => EVENT_MAX,
|
|
31
|
+
FLUSH_WAIT: () => FLUSH_WAIT,
|
|
32
|
+
IDLE_MAX: () => IDLE_MAX,
|
|
33
|
+
IMPRESSION_OPT: () => IMPRESSION_OPT,
|
|
34
|
+
ITEM_ATTR: () => ITEM_ATTR,
|
|
35
|
+
ITEM_INDEX_ATTR: () => ITEM_INDEX_ATTR,
|
|
36
|
+
ITEM_NAME_ATTR: () => ITEM_NAME_ATTR,
|
|
37
|
+
LOG_PATH: () => LOG_PATH,
|
|
38
|
+
MASK_MAX: () => MASK_MAX,
|
|
39
|
+
MAX_AGE: () => MAX_AGE,
|
|
40
|
+
MAX_HELD: () => MAX_HELD,
|
|
41
|
+
NAME_MAX: () => NAME_MAX,
|
|
42
|
+
PARAM_LIMIT: () => PARAM_LIMIT,
|
|
43
|
+
PARAM_PREFIX: () => PARAM_PREFIX,
|
|
44
|
+
RESERVED: () => RESERVED,
|
|
45
|
+
RETRY_MAX: () => RETRY_MAX,
|
|
46
|
+
ROUTE_EVENT: () => ROUTE_EVENT,
|
|
47
|
+
SECTION_ATTR: () => SECTION_ATTR,
|
|
48
|
+
SID_NAME: () => SID_NAME,
|
|
49
|
+
TEXT_MAX: () => TEXT_MAX,
|
|
50
|
+
UTM_MAX: () => UTM_MAX,
|
|
51
|
+
VERSION: () => VERSION,
|
|
52
|
+
backoff: () => backoff,
|
|
53
|
+
checkName: () => checkName,
|
|
54
|
+
checkParams: () => checkParams,
|
|
55
|
+
destroy: () => destroy,
|
|
56
|
+
events: () => events,
|
|
57
|
+
flush: () => flush,
|
|
58
|
+
identify: () => identify,
|
|
59
|
+
ids: () => ids,
|
|
60
|
+
init: () => init,
|
|
61
|
+
logUrl: () => logUrl,
|
|
62
|
+
mask: () => mask,
|
|
63
|
+
queue: () => queue,
|
|
64
|
+
readEvent: () => readEvent,
|
|
65
|
+
readImpression: () => readImpression,
|
|
66
|
+
send: () => send,
|
|
67
|
+
setConsent: () => setConsent,
|
|
68
|
+
sizeOf: () => sizeOf,
|
|
69
|
+
trim: () => trim
|
|
70
|
+
});
|
|
71
|
+
module.exports = __toCommonJS(index_exports);
|
|
72
|
+
|
|
73
|
+
// src/batch.ts
|
|
74
|
+
var BATCH_MAX = 20;
|
|
75
|
+
var FLUSH_WAIT = 5e3;
|
|
76
|
+
var Batch = class {
|
|
77
|
+
constructor(ship, size = BATCH_MAX, wait = FLUSH_WAIT) {
|
|
78
|
+
this.ship = ship;
|
|
79
|
+
this.size = size;
|
|
80
|
+
this.wait = wait;
|
|
81
|
+
this.buffer = [];
|
|
82
|
+
this.timer = null;
|
|
83
|
+
}
|
|
84
|
+
add(event) {
|
|
85
|
+
this.buffer.push(event);
|
|
86
|
+
if (this.buffer.length >= this.size) {
|
|
87
|
+
this.flush();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.timer === null) this.timer = setTimeout(() => this.flush(), this.wait);
|
|
91
|
+
}
|
|
92
|
+
flush() {
|
|
93
|
+
if (this.timer !== null) {
|
|
94
|
+
clearTimeout(this.timer);
|
|
95
|
+
this.timer = null;
|
|
96
|
+
}
|
|
97
|
+
if (this.buffer.length === 0) return;
|
|
98
|
+
const evts = this.buffer;
|
|
99
|
+
this.buffer = [];
|
|
100
|
+
this.ship(evts);
|
|
101
|
+
}
|
|
102
|
+
/** 대기 중인 이벤트. 육안 검증용이다 */
|
|
103
|
+
pending() {
|
|
104
|
+
return [...this.buffer];
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/limits.ts
|
|
109
|
+
var NAME_MAX = 40;
|
|
110
|
+
var TEXT_MAX = 50;
|
|
111
|
+
var ATTR_MAX = 200;
|
|
112
|
+
var UTM_MAX = 100;
|
|
113
|
+
var EVENT_MAX = 16 * 1024;
|
|
114
|
+
var PARAM_LIMIT = {
|
|
115
|
+
keys: 25,
|
|
116
|
+
keyLength: 40,
|
|
117
|
+
valueLength: 500
|
|
118
|
+
};
|
|
119
|
+
function checkParams(params) {
|
|
120
|
+
const issues = [];
|
|
121
|
+
const keys = Object.keys(params);
|
|
122
|
+
if (keys.length > PARAM_LIMIT.keys) {
|
|
123
|
+
issues.push(`params key count ${keys.length} > ${PARAM_LIMIT.keys}`);
|
|
124
|
+
}
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
if (key.length > PARAM_LIMIT.keyLength) {
|
|
127
|
+
issues.push(`params key "${key}" length > ${PARAM_LIMIT.keyLength}`);
|
|
128
|
+
}
|
|
129
|
+
const value = params[key];
|
|
130
|
+
if (typeof value === "string" && value.length > PARAM_LIMIT.valueLength) {
|
|
131
|
+
issues.push(`params value of "${key}" length > ${PARAM_LIMIT.valueLength}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return issues;
|
|
135
|
+
}
|
|
136
|
+
function sizeOf(value) {
|
|
137
|
+
try {
|
|
138
|
+
return new TextEncoder().encode(JSON.stringify(value)).length;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
var clipped = /* @__PURE__ */ new Set();
|
|
144
|
+
function clip(value, max, field) {
|
|
145
|
+
if (value.length <= max) return value;
|
|
146
|
+
if (!clipped.has(field)) {
|
|
147
|
+
clipped.add(field);
|
|
148
|
+
console.warn(`[bstage-analytics] E_ATTR_CLIPPED: "${field}" exceeds the ${max}-char limit`);
|
|
149
|
+
}
|
|
150
|
+
return value.slice(0, max);
|
|
151
|
+
}
|
|
152
|
+
var RESERVED = [
|
|
153
|
+
"page_view",
|
|
154
|
+
"click",
|
|
155
|
+
"impression",
|
|
156
|
+
"scroll_depth",
|
|
157
|
+
"runtime_error"
|
|
158
|
+
];
|
|
159
|
+
function isReserved(name) {
|
|
160
|
+
return typeof name === "string" && RESERVED.includes(name);
|
|
161
|
+
}
|
|
162
|
+
function checkName(name) {
|
|
163
|
+
if (typeof name !== "string") return false;
|
|
164
|
+
return name.length <= NAME_MAX && /^[a-z][a-z0-9_]*$/.test(name);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/mask.ts
|
|
168
|
+
var MASK_MAX = 2e3;
|
|
169
|
+
function mask(text) {
|
|
170
|
+
return text.slice(0, MASK_MAX).replace(
|
|
171
|
+
/([?&#](?:access_token|refresh_token|id_token|token|key|apikey|api_key|password|passwd|secret|code|auth|session|sig|signature|state|ticket|otp|invite)=)[^&\s#]+/gi,
|
|
172
|
+
"$1***"
|
|
173
|
+
).replace(/\bBearer\s+[\w.-]+/gi, "Bearer ***").replace(/\beyJ[\w-]{6,}\.[\w-]+\.[\w-]+/g, "***").replace(/(\/\/)[^/@\s:]+:[^/@\s]+@/g, "$1***:***@");
|
|
174
|
+
}
|
|
175
|
+
function maskUrl(url) {
|
|
176
|
+
return url ? mask(url) : void 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/click.ts
|
|
180
|
+
var CLICK_ATTR = "data-log-click";
|
|
181
|
+
var SECTION_ATTR = "data-log-section";
|
|
182
|
+
var EVENT_ATTR = "data-log-event";
|
|
183
|
+
var PARAM_PREFIX = "data-log-param-";
|
|
184
|
+
var TARGET = `a, button, [${CLICK_ATTR}], [${EVENT_ATTR}]`;
|
|
185
|
+
function findTarget(event) {
|
|
186
|
+
for (const node of event.composedPath()) {
|
|
187
|
+
if (node instanceof Element && node.matches(TARGET)) return node;
|
|
188
|
+
}
|
|
189
|
+
const target = event.target;
|
|
190
|
+
return target instanceof Element ? target.closest(TARGET) : null;
|
|
191
|
+
}
|
|
192
|
+
function readClick(node, x, y) {
|
|
193
|
+
return {
|
|
194
|
+
tag: node.tagName.toLowerCase(),
|
|
195
|
+
id: node.id ? clip(node.id, ATTR_MAX, "click.id") : void 0,
|
|
196
|
+
text: readText(node),
|
|
197
|
+
href: node.tagName === "A" ? maskUrl(node.href) : void 0,
|
|
198
|
+
x,
|
|
199
|
+
y,
|
|
200
|
+
section: readSection(node)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function readSection(node) {
|
|
204
|
+
const section = node.closest(`[${SECTION_ATTR}]`)?.getAttribute(SECTION_ATTR);
|
|
205
|
+
return section ? clip(section, ATTR_MAX, SECTION_ATTR) : void 0;
|
|
206
|
+
}
|
|
207
|
+
function readEvent(node) {
|
|
208
|
+
const owner = node.closest(`[${EVENT_ATTR}]`);
|
|
209
|
+
const name = owner?.getAttribute(EVENT_ATTR)?.trim();
|
|
210
|
+
if (!owner || !name) return null;
|
|
211
|
+
return { name, params: readParams(owner) };
|
|
212
|
+
}
|
|
213
|
+
function readParams(owner) {
|
|
214
|
+
let params;
|
|
215
|
+
let count = 0;
|
|
216
|
+
for (const attr of owner.getAttributeNames()) {
|
|
217
|
+
if (!attr.startsWith(PARAM_PREFIX)) continue;
|
|
218
|
+
if (count >= PARAM_LIMIT.keys) break;
|
|
219
|
+
const key = toCamel(attr.slice(PARAM_PREFIX.length));
|
|
220
|
+
const value = owner.getAttribute(attr)?.trim();
|
|
221
|
+
if (!okKey(key) || !value) continue;
|
|
222
|
+
params = { ...params, [key]: clip(mask(value), ATTR_MAX, PARAM_PREFIX) };
|
|
223
|
+
count += 1;
|
|
224
|
+
}
|
|
225
|
+
return params;
|
|
226
|
+
}
|
|
227
|
+
var UNSAFE_KEYS = ["constructor", "prototype", "__proto__"];
|
|
228
|
+
function okKey(key) {
|
|
229
|
+
if (key.length === 0 || key.length > PARAM_LIMIT.keyLength) return false;
|
|
230
|
+
if (UNSAFE_KEYS.includes(key.toLowerCase())) return false;
|
|
231
|
+
return /^[a-z][a-zA-Z0-9_]*$/.test(key);
|
|
232
|
+
}
|
|
233
|
+
function toCamel(key) {
|
|
234
|
+
return key.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
|
|
235
|
+
}
|
|
236
|
+
function readText(node) {
|
|
237
|
+
const text = node.textContent?.replace(/\s+/g, " ").trim();
|
|
238
|
+
if (!text) return void 0;
|
|
239
|
+
return mask(text).slice(0, TEXT_MAX);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/config.ts
|
|
243
|
+
var LOG_PATH = "/api/v2/logs/analytics";
|
|
244
|
+
var HALTED = "; analytics did not start";
|
|
245
|
+
function checkConfig(config, base = pageBase()) {
|
|
246
|
+
if (!config.key) return `E_KEY_MISSING: init({ key }) is missing${HALTED}`;
|
|
247
|
+
if (!config.endpoint) return `E_ENDPOINT_MISSING: init({ endpoint }) is missing${HALTED}`;
|
|
248
|
+
const issue = endpointIssue(config.endpoint, base) ?? (config.path === void 0 ? null : pathIssue(config.path));
|
|
249
|
+
return issue && `${issue}${HALTED}`;
|
|
250
|
+
}
|
|
251
|
+
function pathIssue(path) {
|
|
252
|
+
if (path === "") return null;
|
|
253
|
+
if (path.includes("\\") || [...path].some(isControl)) {
|
|
254
|
+
return "E_PATH_ILLEGAL_CHAR: path contains a control character or a backslash";
|
|
255
|
+
}
|
|
256
|
+
if (/[?#]/.test(path)) return "E_PATH_QUERY_OR_FRAGMENT: path contains a query or a fragment";
|
|
257
|
+
if (!path.startsWith("/")) return 'E_PATH_NOT_ABSOLUTE: path does not start with "/"';
|
|
258
|
+
if (/%(2f|5c|25)/i.test(path)) {
|
|
259
|
+
return "E_PATH_ENCODED: path contains an encoded separator or percent (%2F, %5C, %25)";
|
|
260
|
+
}
|
|
261
|
+
const dots = path.split("/").map((seg) => seg.toLowerCase().replace(/%2e/g, "."));
|
|
262
|
+
if (dots.some((seg) => seg === "." || seg === "..")) {
|
|
263
|
+
return 'E_PATH_DOT_SEGMENT: path contains a "." or ".." segment';
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
function endpointIssue(endpoint, base) {
|
|
268
|
+
if (endpoint.trim() === "") return "E_ENDPOINT_EMPTY: endpoint is empty";
|
|
269
|
+
if (endpoint.includes("\\") || [...endpoint].some(isControl)) {
|
|
270
|
+
return "E_ENDPOINT_ILLEGAL_CHAR: endpoint contains a control character or a backslash";
|
|
271
|
+
}
|
|
272
|
+
if (/[?#]/.test(endpoint)) {
|
|
273
|
+
return "E_ENDPOINT_QUERY_OR_FRAGMENT: endpoint contains a query or a fragment";
|
|
274
|
+
}
|
|
275
|
+
let url;
|
|
276
|
+
try {
|
|
277
|
+
url = new URL(endpoint, base);
|
|
278
|
+
} catch {
|
|
279
|
+
return "E_ENDPOINT_UNPARSABLE: endpoint cannot be parsed as a URL";
|
|
280
|
+
}
|
|
281
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
282
|
+
return `E_ENDPOINT_SCHEME: endpoint scheme is not allowed: ${url.protocol}`;
|
|
283
|
+
}
|
|
284
|
+
if (url.username || url.password) {
|
|
285
|
+
return "E_ENDPOINT_USERINFO: endpoint contains userinfo (username or password)";
|
|
286
|
+
}
|
|
287
|
+
if (!/^https?:\/\//i.test(endpoint) && url.origin !== new URL(base).origin) {
|
|
288
|
+
return "E_ENDPOINT_CROSS_ORIGIN: relative endpoint resolves to a different origin";
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
function logUrl(endpoint, base = pageBase(), path = LOG_PATH) {
|
|
293
|
+
const url = new URL(endpoint, base);
|
|
294
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
|
|
295
|
+
url.search = "";
|
|
296
|
+
url.hash = "";
|
|
297
|
+
return url.href;
|
|
298
|
+
}
|
|
299
|
+
function isControl(ch) {
|
|
300
|
+
return ch < " " || ch === "\x7F";
|
|
301
|
+
}
|
|
302
|
+
function pageBase() {
|
|
303
|
+
return typeof location === "undefined" ? "https://localhost" : location.href;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/engagement.ts
|
|
307
|
+
var Engagement = class {
|
|
308
|
+
constructor(now = Date.now) {
|
|
309
|
+
this.now = now;
|
|
310
|
+
this.total = 0;
|
|
311
|
+
this.from = null;
|
|
312
|
+
}
|
|
313
|
+
show() {
|
|
314
|
+
if (this.from === null) this.from = this.now();
|
|
315
|
+
}
|
|
316
|
+
hide() {
|
|
317
|
+
if (this.from === null) return;
|
|
318
|
+
this.total += this.now() - this.from;
|
|
319
|
+
this.from = null;
|
|
320
|
+
}
|
|
321
|
+
elapsed() {
|
|
322
|
+
return this.from === null ? this.total : this.total + (this.now() - this.from);
|
|
323
|
+
}
|
|
324
|
+
/** 라우트 전환 시점에 누적값을 전달하고 0으로 초기화 */
|
|
325
|
+
take() {
|
|
326
|
+
const elapsed = this.elapsed();
|
|
327
|
+
this.total = 0;
|
|
328
|
+
this.from = this.from === null ? null : this.now();
|
|
329
|
+
return elapsed;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/version.ts
|
|
334
|
+
var VERSION = "0.9.0";
|
|
335
|
+
|
|
336
|
+
// src/env.ts
|
|
337
|
+
function readEnv() {
|
|
338
|
+
return {
|
|
339
|
+
ua: navigator.userAgent,
|
|
340
|
+
lang: navigator.language,
|
|
341
|
+
screen: { w: screen.width, h: screen.height },
|
|
342
|
+
viewport: { w: innerWidth, h: innerHeight },
|
|
343
|
+
timezone: timezone(),
|
|
344
|
+
sdkVer: VERSION
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function timezone() {
|
|
348
|
+
try {
|
|
349
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
|
350
|
+
} catch {
|
|
351
|
+
return "";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/store.ts
|
|
356
|
+
var cookieStore = {
|
|
357
|
+
get(name) {
|
|
358
|
+
const prefix = `${name}=`;
|
|
359
|
+
for (const chunk of document.cookie.split("; ")) {
|
|
360
|
+
if (!chunk.startsWith(prefix)) continue;
|
|
361
|
+
const raw = chunk.slice(prefix.length);
|
|
362
|
+
try {
|
|
363
|
+
return decodeURIComponent(raw);
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
},
|
|
370
|
+
set(name, value, maxAge) {
|
|
371
|
+
const v = encodeURIComponent(value);
|
|
372
|
+
document.cookie = `${name}=${v}; path=/; max-age=${maxAge}; SameSite=Lax; Secure`;
|
|
373
|
+
},
|
|
374
|
+
remove(name) {
|
|
375
|
+
document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/uuid.ts
|
|
380
|
+
function uuid() {
|
|
381
|
+
const c = globalThis.crypto;
|
|
382
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
383
|
+
if (!c?.getRandomValues)
|
|
384
|
+
throw new Error("[bstage-analytics] crypto\uB97C \uC4F8 \uC218 \uC5C6\uC5B4 \uC2DD\uBCC4\uC790\uB97C \uB9CC\uB4E4 \uC218 \uC5C6\uB2E4");
|
|
385
|
+
const bytes = new Uint8Array(16);
|
|
386
|
+
c.getRandomValues(bytes);
|
|
387
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
388
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
389
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
390
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/identity.ts
|
|
394
|
+
var BID_NAME = "_bsa_bid";
|
|
395
|
+
var SID_NAME = "_bsa_sid";
|
|
396
|
+
var BID_AGE = 365 * 24 * 60 * 60;
|
|
397
|
+
var IDLE_MAX = 30 * 60 * 1e3;
|
|
398
|
+
var Identity = class {
|
|
399
|
+
constructor(store = cookieStore, now = Date.now) {
|
|
400
|
+
this.store = store;
|
|
401
|
+
this.now = now;
|
|
402
|
+
this.granted = false;
|
|
403
|
+
this.user = {};
|
|
404
|
+
}
|
|
405
|
+
get tabSId() {
|
|
406
|
+
return this.tabSIdValue ?? (this.tabSIdValue = uuid());
|
|
407
|
+
}
|
|
408
|
+
get consent() {
|
|
409
|
+
return { analytics: this.granted };
|
|
410
|
+
}
|
|
411
|
+
setConsent(analytics) {
|
|
412
|
+
const granted = analytics === true;
|
|
413
|
+
this.granted = granted;
|
|
414
|
+
if (granted) return;
|
|
415
|
+
this.store.remove(BID_NAME);
|
|
416
|
+
this.store.remove(SID_NAME);
|
|
417
|
+
this.user = {};
|
|
418
|
+
}
|
|
419
|
+
identify(userId, props) {
|
|
420
|
+
this.user = props ? { id: userId, props } : { id: userId };
|
|
421
|
+
}
|
|
422
|
+
touch() {
|
|
423
|
+
const base = { tabSId: this.tabSId, consent: this.consent };
|
|
424
|
+
if (!this.granted) return base;
|
|
425
|
+
const session = this.rollSession();
|
|
426
|
+
return {
|
|
427
|
+
...base,
|
|
428
|
+
bId: this.browserId(),
|
|
429
|
+
sId: session.id,
|
|
430
|
+
startedAt: session.startedAt,
|
|
431
|
+
user: this.user.id ? this.user : void 0
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
peek() {
|
|
435
|
+
const base = { tabSId: this.tabSId, consent: this.consent };
|
|
436
|
+
if (!this.granted) return base;
|
|
437
|
+
const session = this.parse(this.store.get(SID_NAME));
|
|
438
|
+
return {
|
|
439
|
+
...base,
|
|
440
|
+
bId: this.store.get(BID_NAME) ?? void 0,
|
|
441
|
+
sId: session?.id,
|
|
442
|
+
startedAt: session?.startedAt,
|
|
443
|
+
user: this.user.id ? this.user : void 0
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
browserId() {
|
|
447
|
+
const saved = this.store.get(BID_NAME);
|
|
448
|
+
if (saved) return saved;
|
|
449
|
+
const issued = uuid();
|
|
450
|
+
this.store.set(BID_NAME, issued, BID_AGE);
|
|
451
|
+
return issued;
|
|
452
|
+
}
|
|
453
|
+
rollSession() {
|
|
454
|
+
const at = this.now();
|
|
455
|
+
const prev = this.parse(this.store.get(SID_NAME));
|
|
456
|
+
const next = prev && at - prev.seenAt <= IDLE_MAX ? { ...prev, seenAt: at } : { id: uuid(), startedAt: at, seenAt: at };
|
|
457
|
+
this.store.set(SID_NAME, `${next.id}|${next.startedAt}|${next.seenAt}`, IDLE_MAX / 1e3);
|
|
458
|
+
return next;
|
|
459
|
+
}
|
|
460
|
+
parse(raw) {
|
|
461
|
+
if (!raw) return null;
|
|
462
|
+
const [id, startedAt, seenAt] = raw.split("|");
|
|
463
|
+
if (!id || !startedAt || !seenAt) return null;
|
|
464
|
+
const s = Number(startedAt);
|
|
465
|
+
const v = Number(seenAt);
|
|
466
|
+
return Number.isFinite(s) && Number.isFinite(v) ? { id, startedAt: s, seenAt: v } : null;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// src/impression.ts
|
|
471
|
+
var ITEM_ATTR = "data-log-item";
|
|
472
|
+
var ITEM_NAME_ATTR = "data-log-item-name";
|
|
473
|
+
var ITEM_INDEX_ATTR = "data-log-item-index";
|
|
474
|
+
var IMPRESSION_OPT = {
|
|
475
|
+
threshold: 0.5,
|
|
476
|
+
duration: 2e3,
|
|
477
|
+
rootMargin: "0px"
|
|
478
|
+
};
|
|
479
|
+
function readImpression(node) {
|
|
480
|
+
const itemId = node.getAttribute(ITEM_ATTR)?.trim();
|
|
481
|
+
if (!itemId) return null;
|
|
482
|
+
return {
|
|
483
|
+
// ⛔ `click.ts`의 reader를 그대로 쓴다 — 같은 키여야 `params.section`으로 조인해 CTR이 나온다
|
|
484
|
+
section: readSection(node),
|
|
485
|
+
// ⚠️ `itemId`는 마스킹하지 않는다 — 조인 키라 값이 바뀌면 지표가 끊긴다. 길이 상한만 건다
|
|
486
|
+
itemId: clip(itemId, ATTR_MAX, ITEM_ATTR),
|
|
487
|
+
itemName: readName(node),
|
|
488
|
+
itemIndex: readIndex(node)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function readName(node) {
|
|
492
|
+
const name = node.getAttribute(ITEM_NAME_ATTR)?.replace(/\s+/g, " ").trim();
|
|
493
|
+
if (!name) return void 0;
|
|
494
|
+
return mask(name).slice(0, TEXT_MAX);
|
|
495
|
+
}
|
|
496
|
+
function readIndex(node) {
|
|
497
|
+
const raw = node.getAttribute(ITEM_INDEX_ATTR);
|
|
498
|
+
if (raw === null || raw.trim() === "") return void 0;
|
|
499
|
+
const index = Number(raw);
|
|
500
|
+
return Number.isInteger(index) && index >= 0 ? index : void 0;
|
|
501
|
+
}
|
|
502
|
+
function resolveOpt(over) {
|
|
503
|
+
const opt = { ...IMPRESSION_OPT };
|
|
504
|
+
if (typeof over?.threshold === "number" && Number.isFinite(over.threshold)) {
|
|
505
|
+
opt.threshold = Math.min(1, Math.max(0, over.threshold));
|
|
506
|
+
}
|
|
507
|
+
if (typeof over?.duration === "number" && Number.isFinite(over.duration)) {
|
|
508
|
+
opt.duration = Math.max(0, over.duration);
|
|
509
|
+
}
|
|
510
|
+
if (typeof over?.rootMargin === "string" && over.rootMargin.trim() !== "") {
|
|
511
|
+
opt.rootMargin = over.rootMargin;
|
|
512
|
+
}
|
|
513
|
+
return opt;
|
|
514
|
+
}
|
|
515
|
+
var ImpressionGate = class {
|
|
516
|
+
constructor(duration, emit) {
|
|
517
|
+
this.duration = duration;
|
|
518
|
+
this.emit = emit;
|
|
519
|
+
this.fired = /* @__PURE__ */ new Set();
|
|
520
|
+
this.timers = /* @__PURE__ */ new Map();
|
|
521
|
+
}
|
|
522
|
+
enter(key, value) {
|
|
523
|
+
if (this.fired.has(key) || this.timers.has(key)) return;
|
|
524
|
+
if (this.duration <= 0) {
|
|
525
|
+
this.fire(key, value);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.timers.set(
|
|
529
|
+
key,
|
|
530
|
+
setTimeout(() => this.fire(key, value), this.duration)
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
leave(key) {
|
|
534
|
+
const timer = this.timers.get(key);
|
|
535
|
+
if (timer === void 0) return;
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
this.timers.delete(key);
|
|
538
|
+
}
|
|
539
|
+
/** 라우트 전환. 발화 이력과 대기 중인 체류를 전부 되돌린다 */
|
|
540
|
+
reset() {
|
|
541
|
+
this.stop();
|
|
542
|
+
this.fired.clear();
|
|
543
|
+
}
|
|
544
|
+
/** 대기 중인 체류만 취소한다. 발화 이력은 남는다 */
|
|
545
|
+
stop() {
|
|
546
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
547
|
+
this.timers.clear();
|
|
548
|
+
}
|
|
549
|
+
fire(key, value) {
|
|
550
|
+
this.timers.delete(key);
|
|
551
|
+
this.fired.add(key);
|
|
552
|
+
this.emit(value);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
var Impressions = class {
|
|
556
|
+
constructor(opt, emit) {
|
|
557
|
+
this.opt = opt;
|
|
558
|
+
this.io = null;
|
|
559
|
+
this.mos = /* @__PURE__ */ new Set();
|
|
560
|
+
this.roots = /* @__PURE__ */ new WeakSet();
|
|
561
|
+
this.seen = /* @__PURE__ */ new WeakSet();
|
|
562
|
+
this.gate = new ImpressionGate(opt.duration, emit);
|
|
563
|
+
}
|
|
564
|
+
/** 브라우저가 두 Observer를 지원하지 않으면 조용히 아무 것도 하지 않는다 */
|
|
565
|
+
start() {
|
|
566
|
+
if (this.io !== null) return;
|
|
567
|
+
this.io = makeObserver(this.opt, (entries) => this.onIntersect(entries));
|
|
568
|
+
if (this.io === null) return;
|
|
569
|
+
this.walk(document);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* 라우트 전환. ⛔ 관찰을 통째로 다시 건다 — 발화 이력만 지우면 이미 화면에 있던 대상은
|
|
573
|
+
* 교차 상태가 변하지 않아 IntersectionObserver가 다시 알려주지 않는다.
|
|
574
|
+
*/
|
|
575
|
+
reset() {
|
|
576
|
+
this.stop();
|
|
577
|
+
this.start();
|
|
578
|
+
}
|
|
579
|
+
stop() {
|
|
580
|
+
this.gate.reset();
|
|
581
|
+
this.io?.disconnect();
|
|
582
|
+
this.io = null;
|
|
583
|
+
for (const mo of this.mos) mo.disconnect();
|
|
584
|
+
this.mos.clear();
|
|
585
|
+
this.roots = /* @__PURE__ */ new WeakSet();
|
|
586
|
+
this.seen = /* @__PURE__ */ new WeakSet();
|
|
587
|
+
}
|
|
588
|
+
onIntersect(entries) {
|
|
589
|
+
for (const entry of entries) {
|
|
590
|
+
const params = readImpression(entry.target);
|
|
591
|
+
if (params === null) continue;
|
|
592
|
+
const key = `${params.section ?? ""}\0${params.itemId}`;
|
|
593
|
+
if (entry.isIntersecting && entry.intersectionRatio >= this.opt.threshold) {
|
|
594
|
+
this.gate.enter(key, params);
|
|
595
|
+
} else {
|
|
596
|
+
this.gate.leave(key);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** ⚠️ `start()`·`reset()`에서만 부른다 — mutation마다 전체를 훑으면 고객사 페이지가 무거워진다 */
|
|
601
|
+
walk(root) {
|
|
602
|
+
this.register(root);
|
|
603
|
+
this.scan(root);
|
|
604
|
+
}
|
|
605
|
+
/** 루트에 MutationObserver를 단다. 처음 보는 루트일 때만 true다 */
|
|
606
|
+
register(root) {
|
|
607
|
+
if (this.roots.has(root)) return false;
|
|
608
|
+
this.roots.add(root);
|
|
609
|
+
const mo = watch(root, (records) => this.onMutate(records));
|
|
610
|
+
if (mo !== null) this.mos.add(mo);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
scan(root) {
|
|
614
|
+
root.querySelectorAll("*").forEach((el) => this.consider(el));
|
|
615
|
+
}
|
|
616
|
+
/** 추가된 서브트리만 훑는다. 피드는 lazy load가 기본이라 이 경로가 주 경로다 */
|
|
617
|
+
onMutate(records) {
|
|
618
|
+
for (const record of records) {
|
|
619
|
+
record.addedNodes.forEach((node) => {
|
|
620
|
+
if (!(node instanceof Element)) return;
|
|
621
|
+
this.consider(node);
|
|
622
|
+
node.querySelectorAll("*").forEach((el) => this.consider(el));
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* ⛔ `shadowRoot` 재귀를 빼지 말 것. 게스트 템플릿은 Shadow DOM에 마운트되고
|
|
628
|
+
* `document.querySelectorAll`은 그 경계를 넘지 못한다 — 재귀가 없으면 게스트의 대상이
|
|
629
|
+
* 하나도 관찰되지 않는다.
|
|
630
|
+
*
|
|
631
|
+
* ⛔ 처음 보는 루트만 훑는다. `register()` 없이 스캔하면 mutation마다 이미 등록된 루트를
|
|
632
|
+
* 통째로 다시 훑어, shadow host를 반복 추가하는 마크업이 메인 스레드를 점유한다.
|
|
633
|
+
*/
|
|
634
|
+
consider(el) {
|
|
635
|
+
if (el.shadowRoot !== null && this.register(el.shadowRoot)) this.scan(el.shadowRoot);
|
|
636
|
+
if (this.seen.has(el) || !el.hasAttribute(ITEM_ATTR)) return;
|
|
637
|
+
this.seen.add(el);
|
|
638
|
+
this.io?.observe(el);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
function makeObserver(opt, callback) {
|
|
642
|
+
if (typeof IntersectionObserver === "undefined") return null;
|
|
643
|
+
try {
|
|
644
|
+
return new IntersectionObserver(callback, {
|
|
645
|
+
rootMargin: opt.rootMargin,
|
|
646
|
+
threshold: opt.threshold
|
|
647
|
+
});
|
|
648
|
+
} catch {
|
|
649
|
+
return new IntersectionObserver(callback, { threshold: opt.threshold });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function watch(root, callback) {
|
|
653
|
+
if (typeof MutationObserver === "undefined") return null;
|
|
654
|
+
const mo = new MutationObserver(callback);
|
|
655
|
+
mo.observe(root, { childList: true, subtree: true });
|
|
656
|
+
return mo;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/page.ts
|
|
660
|
+
var UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
|
|
661
|
+
function readPage(logicalRef) {
|
|
662
|
+
const { href, pathname } = location;
|
|
663
|
+
return {
|
|
664
|
+
url: mask(href),
|
|
665
|
+
// ⚠️ `pathname`도 마스킹을 지난다 — 길이 상한(`MASK_MAX`)이 함께 딸려 오고,
|
|
666
|
+
// 경로에 실려 오는 리셋 토큰류도 같은 규칙으로 처리된다.
|
|
667
|
+
path: mask(pathname),
|
|
668
|
+
title: maskUrl(document.title),
|
|
669
|
+
ref: maskUrl(document.referrer || void 0),
|
|
670
|
+
logicalRef: maskUrl(logicalRef),
|
|
671
|
+
viewId: uuid()
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function readUtm(search) {
|
|
675
|
+
const query = new URLSearchParams(search);
|
|
676
|
+
const utm = {};
|
|
677
|
+
for (const key of UTM_KEYS) {
|
|
678
|
+
const value = query.get(key);
|
|
679
|
+
if (value) utm[key] = clip(value, UTM_MAX, key);
|
|
680
|
+
}
|
|
681
|
+
return utm;
|
|
682
|
+
}
|
|
683
|
+
function readLoad() {
|
|
684
|
+
const entry = performance?.getEntriesByType?.("navigation")[0];
|
|
685
|
+
return entry?.type === "reload" || entry?.type === "back_forward" ? entry.type : "navigate";
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/queue.ts
|
|
689
|
+
var MAX_HELD = 500;
|
|
690
|
+
var MAX_AGE = 3 * 24 * 60 * 60 * 1e3;
|
|
691
|
+
var RETRY_MAX = 5;
|
|
692
|
+
function trim(records, now) {
|
|
693
|
+
const alive = records.filter(
|
|
694
|
+
(held) => now - held.savedAt <= MAX_AGE && held.retryCount < RETRY_MAX
|
|
695
|
+
);
|
|
696
|
+
const sorted = [...alive].sort((a, b) => a.savedAt - b.savedAt);
|
|
697
|
+
const keep = sorted.slice(Math.max(0, sorted.length - MAX_HELD));
|
|
698
|
+
return { keep, dropped: records.length - keep.length };
|
|
699
|
+
}
|
|
700
|
+
function backoff(retryCount) {
|
|
701
|
+
return 1e3 * 2 ** Math.min(retryCount, 4);
|
|
702
|
+
}
|
|
703
|
+
var DB_NAME = "bstage-analytics";
|
|
704
|
+
var STORE_NAME = "held";
|
|
705
|
+
async function wipeStore() {
|
|
706
|
+
try {
|
|
707
|
+
const store = await openStore();
|
|
708
|
+
await store?.clear();
|
|
709
|
+
} catch {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function openStore() {
|
|
714
|
+
let db = await openDb();
|
|
715
|
+
if (db && !db.objectStoreNames.contains(STORE_NAME)) {
|
|
716
|
+
db.close();
|
|
717
|
+
await deleteDb();
|
|
718
|
+
db = await openDb();
|
|
719
|
+
}
|
|
720
|
+
if (!db?.objectStoreNames.contains(STORE_NAME)) return null;
|
|
721
|
+
const run = (mode, body) => new Promise((resolve) => {
|
|
722
|
+
try {
|
|
723
|
+
const request = body(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME));
|
|
724
|
+
request.onsuccess = () => resolve(request.result);
|
|
725
|
+
request.onerror = () => resolve(null);
|
|
726
|
+
} catch {
|
|
727
|
+
resolve(null);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
return {
|
|
731
|
+
// ⛔ 한 트랜잭션으로 쓴다 — 트랜잭션은 생성 순서대로 커밋되는데, 건마다 await하며 열면
|
|
732
|
+
// 두 번째부터는 그 사이 열린 `clear()`보다 늦게 커밋돼 철회로 지운 보관분이 되살아난다.
|
|
733
|
+
// 트랜잭션은 호출 시점에 동기로 열린다 — 호출자가 확인과 호출 사이에 await하지 않으면 된다.
|
|
734
|
+
// 한 건의 요청 오류는 `preventDefault()`로 삼킨다 — 두지 않으면 트랜잭션 전체가 롤백된다.
|
|
735
|
+
// 커밋 여부를 돌려준다: 트랜잭션이 중단되면(용량 초과 등) 아무것도 쓰이지 않았다.
|
|
736
|
+
put(records) {
|
|
737
|
+
return new Promise((resolve) => {
|
|
738
|
+
if (records.length === 0) return resolve(true);
|
|
739
|
+
try {
|
|
740
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
741
|
+
const store = tx.objectStore(STORE_NAME);
|
|
742
|
+
for (const held of records) {
|
|
743
|
+
try {
|
|
744
|
+
store.put(held).onerror = (event) => event.preventDefault();
|
|
745
|
+
} catch {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
tx.oncomplete = () => resolve(true);
|
|
750
|
+
tx.onabort = () => resolve(false);
|
|
751
|
+
} catch {
|
|
752
|
+
resolve(false);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
},
|
|
756
|
+
async all() {
|
|
757
|
+
return await run("readonly", (store) => store.getAll()) ?? [];
|
|
758
|
+
},
|
|
759
|
+
async remove(evtIds) {
|
|
760
|
+
for (const id of evtIds) await run("readwrite", (store) => store.delete(id));
|
|
761
|
+
},
|
|
762
|
+
async clear() {
|
|
763
|
+
await run("readwrite", (store) => store.clear());
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function deleteDb() {
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
try {
|
|
770
|
+
const request = indexedDB.deleteDatabase(DB_NAME);
|
|
771
|
+
request.onsuccess = () => resolve();
|
|
772
|
+
request.onerror = () => resolve();
|
|
773
|
+
request.onblocked = () => resolve();
|
|
774
|
+
} catch {
|
|
775
|
+
resolve();
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
function openDb() {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
try {
|
|
782
|
+
const request = indexedDB.open(DB_NAME, 1);
|
|
783
|
+
request.onupgradeneeded = () => {
|
|
784
|
+
request.result.createObjectStore(STORE_NAME, { keyPath: "evtId" });
|
|
785
|
+
};
|
|
786
|
+
request.onsuccess = () => resolve(request.result);
|
|
787
|
+
request.onerror = () => resolve(null);
|
|
788
|
+
request.onblocked = () => resolve(null);
|
|
789
|
+
} catch {
|
|
790
|
+
resolve(null);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/persist.ts
|
|
796
|
+
var LOCK_PREFIX = "bstage-analytics:owner:";
|
|
797
|
+
var LEGACY = "legacy";
|
|
798
|
+
var Persist = class {
|
|
799
|
+
constructor(now = Date.now, open = openStore, locks = globalThis.navigator?.locks ?? null) {
|
|
800
|
+
this.now = now;
|
|
801
|
+
this.open = open;
|
|
802
|
+
this.locks = locks;
|
|
803
|
+
this.owner = uuid();
|
|
804
|
+
this.store = null;
|
|
805
|
+
this.enabled = false;
|
|
806
|
+
this.locked = null;
|
|
807
|
+
this.held = /* @__PURE__ */ new Map();
|
|
808
|
+
}
|
|
809
|
+
get durable() {
|
|
810
|
+
return this.store !== null;
|
|
811
|
+
}
|
|
812
|
+
/** 동의 시 IndexedDB를 연다. 열리지 않으면 메모리 보관만 계속한다 */
|
|
813
|
+
async enable() {
|
|
814
|
+
if (this.store) return;
|
|
815
|
+
this.enabled = true;
|
|
816
|
+
const store = await this.open();
|
|
817
|
+
if (!this.enabled) {
|
|
818
|
+
await store?.clear();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (store) await this.lock();
|
|
822
|
+
if (!this.enabled) {
|
|
823
|
+
await store?.clear();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.store = store;
|
|
827
|
+
if (this.store && this.held.size > 0) await this.store.put([...this.held.values()]);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* 동의 철회. 메모리 보관분까지 비우고 저장소를 지운다
|
|
831
|
+
*/
|
|
832
|
+
async disable() {
|
|
833
|
+
this.enabled = false;
|
|
834
|
+
const store = this.store;
|
|
835
|
+
this.store = null;
|
|
836
|
+
this.held.clear();
|
|
837
|
+
if (store) await store.clear();
|
|
838
|
+
else await wipeStore();
|
|
839
|
+
}
|
|
840
|
+
hold(events2, snapshot) {
|
|
841
|
+
const records = events2.map((event) => ({
|
|
842
|
+
evtId: event.evtId,
|
|
843
|
+
event,
|
|
844
|
+
savedAt: this.now(),
|
|
845
|
+
retryCount: 0,
|
|
846
|
+
snapshot,
|
|
847
|
+
owner: this.owner
|
|
848
|
+
}));
|
|
849
|
+
for (const held of records) this.held.set(held.evtId, held);
|
|
850
|
+
void this.store?.put(records);
|
|
851
|
+
}
|
|
852
|
+
/** 전송이 성공한 이벤트를 보관에서 지운다 */
|
|
853
|
+
settle(evtIds) {
|
|
854
|
+
for (const id of evtIds) this.held.delete(id);
|
|
855
|
+
void this.store?.remove(evtIds);
|
|
856
|
+
}
|
|
857
|
+
fail(evtIds) {
|
|
858
|
+
const records = [];
|
|
859
|
+
for (const id of evtIds) {
|
|
860
|
+
const held = this.held.get(id);
|
|
861
|
+
if (held) records.push({ ...held, retryCount: held.retryCount + 1 });
|
|
862
|
+
}
|
|
863
|
+
for (const held of records) this.held.set(held.evtId, held);
|
|
864
|
+
void this.store?.put(records);
|
|
865
|
+
}
|
|
866
|
+
async recover() {
|
|
867
|
+
const store = this.store;
|
|
868
|
+
const stale = [];
|
|
869
|
+
const valid = [];
|
|
870
|
+
for (const held of await store?.all() ?? []) {
|
|
871
|
+
if (held.snapshot) valid.push(held);
|
|
872
|
+
else stale.push(held.evtId);
|
|
873
|
+
}
|
|
874
|
+
if (!this.live(store)) return [];
|
|
875
|
+
const claimed = await this.claim(valid, store);
|
|
876
|
+
if (!this.live(store)) return [];
|
|
877
|
+
for (const held of claimed) {
|
|
878
|
+
if (!this.held.has(held.evtId)) this.held.set(held.evtId, held);
|
|
879
|
+
}
|
|
880
|
+
if (stale.length > 0) {
|
|
881
|
+
void this.store?.remove(stale);
|
|
882
|
+
console.warn(
|
|
883
|
+
`[bstage-analytics] E_HELD_NO_SNAPSHOT: discarded ${stale.length} held event(s) with no identity snapshot`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
const { keep, dropped } = trim([...this.held.values()], this.now());
|
|
887
|
+
if (dropped > 0) {
|
|
888
|
+
const gone = [...this.held.keys()].filter((id) => !keep.some((held) => held.evtId === id));
|
|
889
|
+
this.held.clear();
|
|
890
|
+
void this.store?.remove(gone);
|
|
891
|
+
console.warn(
|
|
892
|
+
`[bstage-analytics] E_HELD_OVERFLOW: discarded ${dropped} held event(s) over the retention limit`
|
|
893
|
+
);
|
|
894
|
+
} else {
|
|
895
|
+
this.held.clear();
|
|
896
|
+
}
|
|
897
|
+
for (const held of keep) this.held.set(held.evtId, held);
|
|
898
|
+
return keep;
|
|
899
|
+
}
|
|
900
|
+
/** 페이지가 죽을 때까지 쥐는 lock. 콜백이 끝나지 않으므로 브라우저만 풀 수 있다 */
|
|
901
|
+
lock() {
|
|
902
|
+
const locks = this.locks;
|
|
903
|
+
if (!locks) return Promise.resolve();
|
|
904
|
+
this.locked ?? (this.locked = new Promise((granted) => {
|
|
905
|
+
locks.request(LOCK_PREFIX + this.owner, () => {
|
|
906
|
+
granted();
|
|
907
|
+
return new Promise(() => {
|
|
908
|
+
});
|
|
909
|
+
}).catch(() => granted());
|
|
910
|
+
}));
|
|
911
|
+
return this.locked;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* 내 보관분과, 주인 탭이 죽은 보관분만 돌려준다. 죽은 탭의 것은 그 탭의 lock 안에서 다시
|
|
915
|
+
* 읽어 소유를 넘겨받는다 — 두 탭이 동시에 넘겨받으려 해도 lock이 한 쪽만 들여보내고, 늦은
|
|
916
|
+
* 쪽은 다시 읽은 목록이 비어 있다.
|
|
917
|
+
*/
|
|
918
|
+
async claim(rows, store) {
|
|
919
|
+
const locks = this.locks;
|
|
920
|
+
if (!locks || !store) return rows;
|
|
921
|
+
const mine = rows.filter((held) => held.owner === this.owner);
|
|
922
|
+
const others = new Set(
|
|
923
|
+
rows.filter((held) => held.owner !== this.owner).map((held) => held.owner ?? LEGACY)
|
|
924
|
+
);
|
|
925
|
+
for (const owner of others) {
|
|
926
|
+
await locks.request(LOCK_PREFIX + owner, { ifAvailable: true }, async (lock) => {
|
|
927
|
+
if (!lock || !this.live(store)) return;
|
|
928
|
+
const orphans = (await store.all()).filter((held) => held.snapshot && (held.owner ?? LEGACY) === owner).map((held) => ({ ...held, owner: this.owner }));
|
|
929
|
+
if (!this.live(store)) return;
|
|
930
|
+
if (await store.put(orphans)) mine.push(...orphans);
|
|
931
|
+
}).catch(() => {
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return mine;
|
|
935
|
+
}
|
|
936
|
+
/** 읽기를 시작한 저장소가 아직 쓰이고 있다 — 그 사이 철회·재동의가 없었다 */
|
|
937
|
+
live(store) {
|
|
938
|
+
return this.store === store && (store === null || this.enabled);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* 지금 보관 중인 것. ⛔ **`recover()`의 반환값 대신 이것을 쓴다** — 반환값은 그 시점에
|
|
942
|
+
* 고정된 사본이라, 돌려주는 사이에 전송이 끝나 `settle()`이 돌면 이미 지워진 이벤트가
|
|
943
|
+
* 목록에는 남는다. 그것을 재전송하면 방금 보낸 것을 한 번 더 보내게 된다.
|
|
944
|
+
*/
|
|
945
|
+
pending() {
|
|
946
|
+
return [...this.held.values()];
|
|
947
|
+
}
|
|
948
|
+
/** 보관 중인 건수. 육안 검증용이다 */
|
|
949
|
+
size() {
|
|
950
|
+
return this.held.size;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// src/route.ts
|
|
955
|
+
var ROUTE_EVENT = "bstage:route";
|
|
956
|
+
var HOOK_FLAG = "__bstageRouteHook";
|
|
957
|
+
function browserHost() {
|
|
958
|
+
return {
|
|
959
|
+
history,
|
|
960
|
+
bus: window,
|
|
961
|
+
flags: window,
|
|
962
|
+
event: () => window.event
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function hookRoute(host = browserHost()) {
|
|
966
|
+
if (host.flags[HOOK_FLAG]) return () => {
|
|
967
|
+
};
|
|
968
|
+
host.flags[HOOK_FLAG] = true;
|
|
969
|
+
let alive = true;
|
|
970
|
+
let popping = false;
|
|
971
|
+
const inPop = () => popping || host.event?.()?.type === "popstate";
|
|
972
|
+
const originals = {};
|
|
973
|
+
const mine = {};
|
|
974
|
+
for (const name of ["pushState", "replaceState"]) {
|
|
975
|
+
const original = host.history[name];
|
|
976
|
+
originals[name] = original;
|
|
977
|
+
const patched = function(...args) {
|
|
978
|
+
const result = original.apply(this, args);
|
|
979
|
+
if (alive && !(name === "replaceState" && inPop())) notify(host, "navigate");
|
|
980
|
+
return result;
|
|
981
|
+
};
|
|
982
|
+
mine[name] = patched;
|
|
983
|
+
host.history[name] = patched;
|
|
984
|
+
}
|
|
985
|
+
const abort = new AbortController();
|
|
986
|
+
host.bus.addEventListener(
|
|
987
|
+
"popstate",
|
|
988
|
+
() => {
|
|
989
|
+
popping = true;
|
|
990
|
+
setTimeout(() => {
|
|
991
|
+
popping = false;
|
|
992
|
+
if (alive) notify(host, "back_forward");
|
|
993
|
+
});
|
|
994
|
+
},
|
|
995
|
+
{ signal: abort.signal }
|
|
996
|
+
);
|
|
997
|
+
return () => {
|
|
998
|
+
alive = false;
|
|
999
|
+
for (const name of ["pushState", "replaceState"]) {
|
|
1000
|
+
if (host.history[name] === mine[name]) host.history[name] = originals[name];
|
|
1001
|
+
}
|
|
1002
|
+
abort.abort();
|
|
1003
|
+
delete host.flags[HOOK_FLAG];
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function onRoute(fn, opts = {}) {
|
|
1007
|
+
const host = opts.host ?? browserHost();
|
|
1008
|
+
host.bus.addEventListener(
|
|
1009
|
+
ROUTE_EVENT,
|
|
1010
|
+
(event) => {
|
|
1011
|
+
const detail = event.detail;
|
|
1012
|
+
if (!isLoadType(detail)) return;
|
|
1013
|
+
Promise.resolve().then(() => fn(detail));
|
|
1014
|
+
},
|
|
1015
|
+
{ signal: opts.signal }
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
var LOAD_TYPES = ["navigate", "reload", "back_forward"];
|
|
1019
|
+
function isLoadType(value) {
|
|
1020
|
+
return typeof value === "string" && LOAD_TYPES.includes(value);
|
|
1021
|
+
}
|
|
1022
|
+
function notify(host, loadType) {
|
|
1023
|
+
host.bus.dispatchEvent(new CustomEvent(ROUTE_EVENT, { detail: loadType }));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/scroll.ts
|
|
1027
|
+
var DEPTHS = [50, 90];
|
|
1028
|
+
var ScrollDepth = class {
|
|
1029
|
+
constructor(emit) {
|
|
1030
|
+
this.emit = emit;
|
|
1031
|
+
this.fired = /* @__PURE__ */ new Set();
|
|
1032
|
+
}
|
|
1033
|
+
check(ratio) {
|
|
1034
|
+
for (const depth of DEPTHS) {
|
|
1035
|
+
if (ratio >= depth && !this.fired.has(depth)) {
|
|
1036
|
+
this.fired.add(depth);
|
|
1037
|
+
this.emit(depth);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
reset() {
|
|
1042
|
+
this.fired.clear();
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
function readRatio(top, viewport, total) {
|
|
1046
|
+
const scrollable = total - viewport;
|
|
1047
|
+
if (scrollable <= 0) return 100;
|
|
1048
|
+
return Math.min(100, (top + viewport) / total * 100);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/transport.ts
|
|
1052
|
+
var BODY_MAX = 48 * 1024;
|
|
1053
|
+
function overBodyMax(body) {
|
|
1054
|
+
return new TextEncoder().encode(body).length > BODY_MAX;
|
|
1055
|
+
}
|
|
1056
|
+
async function post(url, body) {
|
|
1057
|
+
if (navigator.onLine === false) return false;
|
|
1058
|
+
const blob = new Blob([body], { type: "text/plain" });
|
|
1059
|
+
if (navigator.sendBeacon?.(url, blob)) return true;
|
|
1060
|
+
try {
|
|
1061
|
+
await fetch(url, {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
body: blob,
|
|
1064
|
+
keepalive: true,
|
|
1065
|
+
mode: "no-cors",
|
|
1066
|
+
referrerPolicy: "no-referrer"
|
|
1067
|
+
});
|
|
1068
|
+
return true;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/tracker.ts
|
|
1075
|
+
var Tracker = class {
|
|
1076
|
+
constructor() {
|
|
1077
|
+
this.identity = new Identity();
|
|
1078
|
+
this.engagement = new Engagement();
|
|
1079
|
+
this.depth = new ScrollDepth(
|
|
1080
|
+
(depth) => this.auto({ name: "scroll_depth", params: { depth } })
|
|
1081
|
+
);
|
|
1082
|
+
/** 노출 관찰. init 전에는 없다 */
|
|
1083
|
+
this.impressions = null;
|
|
1084
|
+
this.config = null;
|
|
1085
|
+
this.env = null;
|
|
1086
|
+
this.page = null;
|
|
1087
|
+
this.warned = false;
|
|
1088
|
+
this.batch = new Batch((evts) => void this.ship(evts));
|
|
1089
|
+
this.persist = new Persist();
|
|
1090
|
+
/**
|
|
1091
|
+
* 배치·전송이 쥐고 있는 이벤트. 보관은 write-ahead라 보관소에는 「아직 전송 차례가 오지
|
|
1092
|
+
* 않은 것」과 「전송에 실패해 다시 보내야 하는 것」이 섞여 있는데, 이 표시가 그 둘을 가른다.
|
|
1093
|
+
*
|
|
1094
|
+
* ⛔ **버퍼를 들여다보는 것으로는 가를 수 없다.** 보관분을 읽는 사이에도 이벤트는 추가되고
|
|
1095
|
+
* flush되므로, 목록을 읽기 전에 잡으면 그 사이 추가된 것을, 읽은 뒤에 잡으면 그 사이
|
|
1096
|
+
* flush된 것을 놓친다. 소유권은 시점이 아니라 상태여야 한다.
|
|
1097
|
+
*
|
|
1098
|
+
* 이전 세션의 보관분은 여기에 없다 — 그래서 탭이 죽은 뒤의 복구는 그대로 동작한다.
|
|
1099
|
+
*/
|
|
1100
|
+
this.owned = /* @__PURE__ */ new Set();
|
|
1101
|
+
this.retryTimer = null;
|
|
1102
|
+
this.abort = null;
|
|
1103
|
+
this.unhook = null;
|
|
1104
|
+
this.revived = false;
|
|
1105
|
+
this.stopped = false;
|
|
1106
|
+
this.snapshot = null;
|
|
1107
|
+
}
|
|
1108
|
+
get ready() {
|
|
1109
|
+
return this.config !== null;
|
|
1110
|
+
}
|
|
1111
|
+
init(config = {}) {
|
|
1112
|
+
if (this.ready) return true;
|
|
1113
|
+
if (typeof document === "undefined") return false;
|
|
1114
|
+
const c = globalThis.crypto;
|
|
1115
|
+
if (!c?.randomUUID && !c?.getRandomValues) {
|
|
1116
|
+
this.warn("E_CRYPTO_UNAVAILABLE: crypto is unavailable; analytics did not start");
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
const merged = strip(config);
|
|
1120
|
+
const issue = checkConfig(merged);
|
|
1121
|
+
if (issue) {
|
|
1122
|
+
this.warn(issue);
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
this.config = merged;
|
|
1126
|
+
this.env = readEnv();
|
|
1127
|
+
this.stopped = false;
|
|
1128
|
+
this.watch();
|
|
1129
|
+
this.impressions = new Impressions(
|
|
1130
|
+
resolveOpt(merged.impression),
|
|
1131
|
+
(params) => this.auto({ name: "impression", params })
|
|
1132
|
+
);
|
|
1133
|
+
this.impressions.start();
|
|
1134
|
+
this.pageView(this.revived ? "navigate" : readLoad());
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
identify(userId, props) {
|
|
1138
|
+
this.identity.identify(userId, safeProps(props));
|
|
1139
|
+
}
|
|
1140
|
+
setConsent(analytics) {
|
|
1141
|
+
this.batch.flush();
|
|
1142
|
+
this.identity.setConsent(analytics);
|
|
1143
|
+
if (analytics === true) {
|
|
1144
|
+
void this.persist.enable().then(() => this.retry());
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (this.retryTimer !== null) {
|
|
1148
|
+
clearTimeout(this.retryTimer);
|
|
1149
|
+
this.retryTimer = null;
|
|
1150
|
+
}
|
|
1151
|
+
void this.persist.disable();
|
|
1152
|
+
}
|
|
1153
|
+
ids() {
|
|
1154
|
+
return this.identity.peek();
|
|
1155
|
+
}
|
|
1156
|
+
flush() {
|
|
1157
|
+
this.batch.flush();
|
|
1158
|
+
}
|
|
1159
|
+
destroy() {
|
|
1160
|
+
if (!this.ready) return;
|
|
1161
|
+
this.stopped = true;
|
|
1162
|
+
this.flush();
|
|
1163
|
+
this.abort?.abort();
|
|
1164
|
+
this.abort = null;
|
|
1165
|
+
this.unhook?.();
|
|
1166
|
+
this.unhook = null;
|
|
1167
|
+
if (this.retryTimer !== null) {
|
|
1168
|
+
clearTimeout(this.retryTimer);
|
|
1169
|
+
this.retryTimer = null;
|
|
1170
|
+
}
|
|
1171
|
+
this.engagement.hide();
|
|
1172
|
+
this.depth.reset();
|
|
1173
|
+
this.impressions?.stop();
|
|
1174
|
+
this.impressions = null;
|
|
1175
|
+
this.config = null;
|
|
1176
|
+
this.env = null;
|
|
1177
|
+
this.page = null;
|
|
1178
|
+
this.revived = true;
|
|
1179
|
+
}
|
|
1180
|
+
events() {
|
|
1181
|
+
return this.batch.pending();
|
|
1182
|
+
}
|
|
1183
|
+
queue() {
|
|
1184
|
+
return { durable: this.persist.durable, held: this.persist.size() };
|
|
1185
|
+
}
|
|
1186
|
+
send(name, params) {
|
|
1187
|
+
if (!this.ready) return;
|
|
1188
|
+
if (!checkName(name)) {
|
|
1189
|
+
console.warn(
|
|
1190
|
+
`[bstage-analytics] E_NAME_INVALID: event name does not match the naming rule; dropped: ${name}`
|
|
1191
|
+
);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (params) {
|
|
1195
|
+
const size = sizeOf(params);
|
|
1196
|
+
if (size === null || size > EVENT_MAX) {
|
|
1197
|
+
console.warn(
|
|
1198
|
+
`[bstage-analytics] E_PARAMS_TOO_LARGE: params exceed the ${EVENT_MAX}B limit; "${name}" dropped`
|
|
1199
|
+
);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
for (const issue of checkParams(params)) {
|
|
1203
|
+
console.warn(`[bstage-analytics] E_PARAMS_LIMIT: ${name}: ${issue}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
this.record(name, params);
|
|
1207
|
+
}
|
|
1208
|
+
/** ⛔ 예약 이름 판정은 여기서만 한다 — `send()`에 두면 자동 계측 경로가 검사를 건너뛴다 */
|
|
1209
|
+
record(name, params) {
|
|
1210
|
+
if (typeof name !== "string" || isReserved(name)) {
|
|
1211
|
+
console.warn(
|
|
1212
|
+
`[bstage-analytics] E_NAME_RESERVED: event name is reserved or not a string; dropped: ${String(name)}`
|
|
1213
|
+
);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
this.emit(name, params);
|
|
1217
|
+
}
|
|
1218
|
+
/** 자동 계측 전용 경로. 예약 이름을 낼 수 있는 유일한 곳이라 SDK 내부에서만 부른다 */
|
|
1219
|
+
auto(event) {
|
|
1220
|
+
this.emit(event.name, event.params);
|
|
1221
|
+
}
|
|
1222
|
+
emit(name, params) {
|
|
1223
|
+
if (!this.ready) return;
|
|
1224
|
+
this.snapshot = this.identity.touch();
|
|
1225
|
+
const event = {
|
|
1226
|
+
evtId: uuid(),
|
|
1227
|
+
ts: Date.now(),
|
|
1228
|
+
name,
|
|
1229
|
+
page: this.page ?? (this.page = readPage(this.prevUrl)),
|
|
1230
|
+
params
|
|
1231
|
+
};
|
|
1232
|
+
this.owned.add(event.evtId);
|
|
1233
|
+
this.batch.add(event);
|
|
1234
|
+
this.persist.hold([event], this.snapshot);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* ⛔ **전송이 시작된 이벤트도 그 전송이 끝날 때까지 소유한다.** 백오프와 `online`이 겹쳐
|
|
1238
|
+
* 돌면 재전송이 두 번 시작될 수 있는데, 표시가 없으면 아직 날아가는 중인 보관분을 두
|
|
1239
|
+
* 번째 재전송이 다시 집어간다. `record()`가 붙인 표시와 같은 표시다(집합이라 겹쳐도 된다).
|
|
1240
|
+
*/
|
|
1241
|
+
async ship(evts, retryCount = 0, held) {
|
|
1242
|
+
const evtIds = evts.map((event) => event.evtId);
|
|
1243
|
+
for (const id of evtIds) this.owned.add(id);
|
|
1244
|
+
try {
|
|
1245
|
+
await this.deliver(evts, evtIds, retryCount, held);
|
|
1246
|
+
} catch {
|
|
1247
|
+
console.warn(
|
|
1248
|
+
`[bstage-analytics] E_SHIP_FAILED: could not serialize or send ${evts.length} event(s)`
|
|
1249
|
+
);
|
|
1250
|
+
this.persist.fail(evtIds);
|
|
1251
|
+
this.scheduleRetry(retryCount);
|
|
1252
|
+
} finally {
|
|
1253
|
+
this.release(evtIds);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/** ⛔ 쪼갤 수 없는 1건이 상한을 넘으면 드롭한다 — 그대로 보내면 브라우저가 조용히 삼킨다 */
|
|
1257
|
+
async deliver(evts, evtIds, retryCount, held) {
|
|
1258
|
+
const config = this.config;
|
|
1259
|
+
const env = this.env;
|
|
1260
|
+
const snapshot = held ?? this.snapshot;
|
|
1261
|
+
if (!config || !env || !snapshot) return;
|
|
1262
|
+
const { consent, startedAt: _startedAt, ...ids2 } = snapshot;
|
|
1263
|
+
const payload = {
|
|
1264
|
+
version: 2,
|
|
1265
|
+
key: config.key,
|
|
1266
|
+
...ids2,
|
|
1267
|
+
env,
|
|
1268
|
+
consent,
|
|
1269
|
+
// 발화 임계값은 **배치당 1회**만 싣는다. 이벤트마다 반복하면 정작 「무엇이 노출됐는가」를
|
|
1270
|
+
// 담을 자리가 없어진다 — `ImpressionParams` 주석 참조.
|
|
1271
|
+
...this.impressions === null ? {} : { impressionOpt: this.impressions.opt },
|
|
1272
|
+
sentAt: Date.now(),
|
|
1273
|
+
retryCount,
|
|
1274
|
+
evts
|
|
1275
|
+
};
|
|
1276
|
+
const body = JSON.stringify(payload);
|
|
1277
|
+
if (overBodyMax(body)) {
|
|
1278
|
+
if (evts.length > 1) {
|
|
1279
|
+
const half = Math.ceil(evts.length / 2);
|
|
1280
|
+
await Promise.all([
|
|
1281
|
+
this.ship(evts.slice(0, half), retryCount, snapshot),
|
|
1282
|
+
this.ship(evts.slice(half), retryCount, snapshot)
|
|
1283
|
+
]);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
console.warn(`[bstage-analytics] \uC774\uBCA4\uD2B8 1\uAC74\uC774 body \uC0C1\uD55C\uC744 \uB118\uC5B4 \uB4DC\uB86D\uD55C\uB2E4: "${evts[0]?.name}"`);
|
|
1287
|
+
this.persist.settle(evtIds);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (await post(logUrl(config.endpoint, void 0, config.path), body)) {
|
|
1291
|
+
this.persist.settle(evtIds);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (navigator.onLine === false) return;
|
|
1295
|
+
this.persist.fail(evtIds);
|
|
1296
|
+
this.scheduleRetry(retryCount);
|
|
1297
|
+
}
|
|
1298
|
+
/** 배치·전송이 쥐고 있던 이벤트를 놓는다. 놓인 것만 재전송 대상이 된다 */
|
|
1299
|
+
release(evtIds) {
|
|
1300
|
+
for (const id of evtIds) this.owned.delete(id);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* 보관분 재전송.
|
|
1304
|
+
*
|
|
1305
|
+
* ⛔ **배치 버퍼가 아직 들고 있는 이벤트는 재전송 대상이 아니다.** 보관은 write-ahead라
|
|
1306
|
+
* 이벤트가 버퍼에 들어가는 시점에 이미 기록된다 — 걸러내지 않으면 여기서 한 번, 버퍼가
|
|
1307
|
+
* 제 차례에 또 한 번 보내 같은 `evtId`가 수집 서버에 두 레코드로 남는다.
|
|
1308
|
+
*
|
|
1309
|
+
* ⛔ **제외는 꺼내는 쪽인 여기서 한다.** 보관을 늦추면 flush 전에 탭이 죽었을 때 복구되지
|
|
1310
|
+
* 않는다 — write-ahead의 목적 자체가 사라진다.
|
|
1311
|
+
*
|
|
1312
|
+
* ⛔ **소유권 표시로 가른다 — 버퍼를 읽는 시점으로 가르지 않는다.** 보관분을 읽는 사이에도
|
|
1313
|
+
* 이벤트는 추가되고 flush되므로 어느 시점에 버퍼를 읽어도 한쪽 창이 남는다(`owned` 참조).
|
|
1314
|
+
*/
|
|
1315
|
+
async retry() {
|
|
1316
|
+
if (this.stopped) return;
|
|
1317
|
+
await this.persist.recover();
|
|
1318
|
+
const records = this.persist.pending().filter((held) => !this.owned.has(held.evtId));
|
|
1319
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1320
|
+
for (const held of records) {
|
|
1321
|
+
const key = JSON.stringify(held.snapshot);
|
|
1322
|
+
const group = groups.get(key);
|
|
1323
|
+
if (group) group.push(held);
|
|
1324
|
+
else groups.set(key, [held]);
|
|
1325
|
+
}
|
|
1326
|
+
for (const group of groups.values()) {
|
|
1327
|
+
for (let at = 0; at < group.length; at += BATCH_MAX) {
|
|
1328
|
+
const slice = group.slice(at, at + BATCH_MAX);
|
|
1329
|
+
const attempt = Math.max(...slice.map((held) => held.retryCount));
|
|
1330
|
+
void this.ship(
|
|
1331
|
+
slice.map((held) => held.event),
|
|
1332
|
+
attempt,
|
|
1333
|
+
slice[0].snapshot
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
scheduleRetry(retryCount) {
|
|
1339
|
+
if (this.stopped || this.retryTimer !== null) return;
|
|
1340
|
+
this.retryTimer = setTimeout(() => {
|
|
1341
|
+
this.retryTimer = null;
|
|
1342
|
+
void this.retry();
|
|
1343
|
+
}, backoff(retryCount));
|
|
1344
|
+
}
|
|
1345
|
+
pageView(loadType, engagementTime) {
|
|
1346
|
+
this.page = readPage(this.prevUrl);
|
|
1347
|
+
this.prevUrl = this.page.url;
|
|
1348
|
+
const { startedAt } = this.identity.touch();
|
|
1349
|
+
this.auto({
|
|
1350
|
+
name: "page_view",
|
|
1351
|
+
params: {
|
|
1352
|
+
loadType,
|
|
1353
|
+
...engagementTime === void 0 ? {} : { engagementTime },
|
|
1354
|
+
...startedAt === void 0 ? {} : { sessionDuration: Date.now() - startedAt },
|
|
1355
|
+
...readUtm(location.search)
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
click(event) {
|
|
1360
|
+
if (!event.isTrusted) return;
|
|
1361
|
+
const el = findTarget(event);
|
|
1362
|
+
if (!el) return;
|
|
1363
|
+
this.auto({ name: "click", params: readClick(el, event.pageX, event.pageY) });
|
|
1364
|
+
const custom = readEvent(el);
|
|
1365
|
+
if (custom) this.send(custom.name, custom.params);
|
|
1366
|
+
}
|
|
1367
|
+
watch() {
|
|
1368
|
+
const abort = this.abort = new AbortController();
|
|
1369
|
+
const { signal } = abort;
|
|
1370
|
+
if (document.visibilityState === "visible") this.engagement.show();
|
|
1371
|
+
document.addEventListener(
|
|
1372
|
+
"visibilitychange",
|
|
1373
|
+
() => {
|
|
1374
|
+
if (document.visibilityState === "visible") this.engagement.show();
|
|
1375
|
+
else this.engagement.hide();
|
|
1376
|
+
},
|
|
1377
|
+
{ signal }
|
|
1378
|
+
);
|
|
1379
|
+
document.addEventListener(
|
|
1380
|
+
"visibilitychange",
|
|
1381
|
+
() => {
|
|
1382
|
+
if (document.visibilityState === "hidden") this.batch.flush();
|
|
1383
|
+
},
|
|
1384
|
+
{ signal }
|
|
1385
|
+
);
|
|
1386
|
+
addEventListener("pagehide", () => this.batch.flush(), { signal });
|
|
1387
|
+
addEventListener("online", () => void this.retry(), { signal });
|
|
1388
|
+
document.addEventListener("click", (event) => this.click(event), { capture: true, signal });
|
|
1389
|
+
let ticking = false;
|
|
1390
|
+
addEventListener(
|
|
1391
|
+
"scroll",
|
|
1392
|
+
(event) => {
|
|
1393
|
+
if (!event.isTrusted || ticking) return;
|
|
1394
|
+
ticking = true;
|
|
1395
|
+
requestAnimationFrame(() => {
|
|
1396
|
+
ticking = false;
|
|
1397
|
+
this.depth.check(readRatio(scrollY, innerHeight, document.documentElement.scrollHeight));
|
|
1398
|
+
});
|
|
1399
|
+
},
|
|
1400
|
+
{ passive: true, signal }
|
|
1401
|
+
);
|
|
1402
|
+
this.unhook = hookRoute();
|
|
1403
|
+
onRoute(
|
|
1404
|
+
(loadType) => afterPaint(() => {
|
|
1405
|
+
if (location.href === this.prevUrl) return;
|
|
1406
|
+
this.depth.reset();
|
|
1407
|
+
this.impressions?.reset();
|
|
1408
|
+
this.pageView(loadType, this.engagement.take());
|
|
1409
|
+
}, signal),
|
|
1410
|
+
{ signal }
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
warn(message) {
|
|
1414
|
+
if (this.warned) return;
|
|
1415
|
+
this.warned = true;
|
|
1416
|
+
console.warn(`[bstage-analytics] ${message}`);
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
function safeProps(props) {
|
|
1420
|
+
if (!props) return void 0;
|
|
1421
|
+
let json;
|
|
1422
|
+
try {
|
|
1423
|
+
json = JSON.stringify(props);
|
|
1424
|
+
} catch {
|
|
1425
|
+
json = void 0;
|
|
1426
|
+
}
|
|
1427
|
+
if (json === void 0) {
|
|
1428
|
+
console.warn(
|
|
1429
|
+
"[bstage-analytics] E_PROPS_UNSERIALIZABLE: identify props could not be serialized; props dropped, userId kept"
|
|
1430
|
+
);
|
|
1431
|
+
return void 0;
|
|
1432
|
+
}
|
|
1433
|
+
if (new TextEncoder().encode(json).length > EVENT_MAX) {
|
|
1434
|
+
console.warn(
|
|
1435
|
+
`[bstage-analytics] E_PROPS_TOO_LARGE: identify props exceed the ${EVENT_MAX}B limit; props dropped, userId kept`
|
|
1436
|
+
);
|
|
1437
|
+
return void 0;
|
|
1438
|
+
}
|
|
1439
|
+
return JSON.parse(json);
|
|
1440
|
+
}
|
|
1441
|
+
function strip(config) {
|
|
1442
|
+
return Object.fromEntries(Object.entries(config).filter(([, v]) => v !== void 0));
|
|
1443
|
+
}
|
|
1444
|
+
var PAINT_MAX = 100;
|
|
1445
|
+
function afterPaint(fn, signal) {
|
|
1446
|
+
let done = false;
|
|
1447
|
+
const run = () => {
|
|
1448
|
+
if (done || signal.aborted) return;
|
|
1449
|
+
done = true;
|
|
1450
|
+
fn();
|
|
1451
|
+
};
|
|
1452
|
+
requestAnimationFrame(() => requestAnimationFrame(run));
|
|
1453
|
+
setTimeout(run, PAINT_MAX);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/index.ts
|
|
1457
|
+
var tracker = new Tracker();
|
|
1458
|
+
function init(config = {}) {
|
|
1459
|
+
tracker.init(config);
|
|
1460
|
+
}
|
|
1461
|
+
function identify(userId, props) {
|
|
1462
|
+
tracker.identify(userId, props);
|
|
1463
|
+
}
|
|
1464
|
+
function send(name, params) {
|
|
1465
|
+
tracker.send(name, params);
|
|
1466
|
+
}
|
|
1467
|
+
function setConsent(analytics) {
|
|
1468
|
+
tracker.setConsent(analytics);
|
|
1469
|
+
}
|
|
1470
|
+
function ids() {
|
|
1471
|
+
return tracker.ids();
|
|
1472
|
+
}
|
|
1473
|
+
function flush() {
|
|
1474
|
+
tracker.flush();
|
|
1475
|
+
}
|
|
1476
|
+
function destroy() {
|
|
1477
|
+
tracker.destroy();
|
|
1478
|
+
}
|
|
1479
|
+
function events() {
|
|
1480
|
+
return tracker.events();
|
|
1481
|
+
}
|
|
1482
|
+
function queue() {
|
|
1483
|
+
return tracker.queue();
|
|
1484
|
+
}
|
|
1485
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1486
|
+
0 && (module.exports = {
|
|
1487
|
+
ATTR_MAX,
|
|
1488
|
+
BATCH_MAX,
|
|
1489
|
+
BID_NAME,
|
|
1490
|
+
BODY_MAX,
|
|
1491
|
+
CLICK_ATTR,
|
|
1492
|
+
DEPTHS,
|
|
1493
|
+
EVENT_ATTR,
|
|
1494
|
+
EVENT_MAX,
|
|
1495
|
+
FLUSH_WAIT,
|
|
1496
|
+
IDLE_MAX,
|
|
1497
|
+
IMPRESSION_OPT,
|
|
1498
|
+
ITEM_ATTR,
|
|
1499
|
+
ITEM_INDEX_ATTR,
|
|
1500
|
+
ITEM_NAME_ATTR,
|
|
1501
|
+
LOG_PATH,
|
|
1502
|
+
MASK_MAX,
|
|
1503
|
+
MAX_AGE,
|
|
1504
|
+
MAX_HELD,
|
|
1505
|
+
NAME_MAX,
|
|
1506
|
+
PARAM_LIMIT,
|
|
1507
|
+
PARAM_PREFIX,
|
|
1508
|
+
RESERVED,
|
|
1509
|
+
RETRY_MAX,
|
|
1510
|
+
ROUTE_EVENT,
|
|
1511
|
+
SECTION_ATTR,
|
|
1512
|
+
SID_NAME,
|
|
1513
|
+
TEXT_MAX,
|
|
1514
|
+
UTM_MAX,
|
|
1515
|
+
VERSION,
|
|
1516
|
+
backoff,
|
|
1517
|
+
checkName,
|
|
1518
|
+
checkParams,
|
|
1519
|
+
destroy,
|
|
1520
|
+
events,
|
|
1521
|
+
flush,
|
|
1522
|
+
identify,
|
|
1523
|
+
ids,
|
|
1524
|
+
init,
|
|
1525
|
+
logUrl,
|
|
1526
|
+
mask,
|
|
1527
|
+
queue,
|
|
1528
|
+
readEvent,
|
|
1529
|
+
readImpression,
|
|
1530
|
+
send,
|
|
1531
|
+
setConsent,
|
|
1532
|
+
sizeOf,
|
|
1533
|
+
trim
|
|
1534
|
+
});
|