@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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * 전송 스키마. 필드명은 수집 서버와의 계약이라 바꾸지 않으며, 기존 envelope의 상위집합이라
3
+ * 추가 필드는 그대로 통과한다.
4
+ */
5
+ /** 계층 1 — 전송 envelope */
6
+ interface LogBatch {
7
+ /** 기존 파이프라인 호환값. 항상 2 */
8
+ version: 2;
9
+ /** 수집 서버가 이 값으로 스페이스를 해석한다 */
10
+ key: string;
11
+ /**
12
+ * 영속 브라우저 ID. 비동의(익명) 모드에서는 발급·저장하지 않아 없으며,
13
+ * 적재 파이프라인은 `consent.analytics`로 구분한다.
14
+ */
15
+ bId?: string;
16
+ /** 세션 ID (30분 슬라이딩). 익명 모드에서는 없다 — `bId`와 같은 이유다 */
17
+ sId?: string;
18
+ /** 탭 세션 ID. 동의 여부와 무관하게 항상 채운다 — 익명 트래픽의 dedup 그룹핑 키다 */
19
+ tabSId: string;
20
+ user?: User;
21
+ env: Env;
22
+ consent: Consent;
23
+ /** 앱→웹 브릿지 커스텀 필드 */
24
+ bridge?: Bridge;
25
+ /**
26
+ * impression 발화 임계값. **배치당 1회** 싣는다 — 이벤트마다 같은 상수를 반복하지 않는다.
27
+ *
28
+ * ⛔ **값을 버리지 말 것.** 고객사마다 임계값이 다르면 노출 수가 비교 불가능한 값이 되고,
29
+ * 임계값을 바꾸면 그 시점 전후 시계열이 끊긴다. **설정 이력을 남기는 곳이 여기뿐이라**
30
+ * 적재 측이 dimension 조인으로도 풀 수 없다.
31
+ */
32
+ impressionOpt?: ImpressionOpt;
33
+ /** 전송 시각. 수집 서버가 덮어쓴다 */
34
+ sentAt: number;
35
+ /** 재전송 횟수. 영속 큐가 채우며 최초 전송에는 없거나 0이다 */
36
+ retryCount?: number;
37
+ evts: Event[];
38
+ }
39
+ /**
40
+ * 수집 서버가 채우는 필드 — SDK는 채우지 않는다. 고객사 앱에는 관련 헤더도 쿠키도 없어
41
+ * `key`만 싣고 서버가 해석한다.
42
+ */
43
+ interface ServerFields {
44
+ /** 서버가 채운다 */
45
+ spaceId: string;
46
+ }
47
+ interface User {
48
+ /** 고객사가 `identify(userId)`로 주입한다. 호출이 없으면 비로그인으로 간주한다 */
49
+ id?: string;
50
+ type?: string;
51
+ props?: Record<string, unknown>;
52
+ }
53
+ /** 실행 환경. User-Agent는 원문만 싣고 파싱하지 않는다 */
54
+ interface Env {
55
+ ua: string;
56
+ lang: string;
57
+ screen: Size;
58
+ viewport: Size;
59
+ timezone: string;
60
+ sdkVer: string;
61
+ }
62
+ interface Size {
63
+ w: number;
64
+ h: number;
65
+ }
66
+ /** 고객사가 `setConsent()`로 알려준다. SDK 기본값은 비동의(opt-in)다 */
67
+ interface Consent {
68
+ analytics: boolean;
69
+ }
70
+ /** 앱→웹 브릿지가 주입하는 자유 필드 */
71
+ type Bridge = Record<string, unknown>;
72
+ /** 계층 2 — `evts[]` 원소 */
73
+ interface Event {
74
+ /** UUID. 적재 파이프라인의 중복 전송 방지 키다 */
75
+ evtId: string;
76
+ ts: number;
77
+ /** snake_case, 40자 이하. 예약어는 `ReservedName` 참조 */
78
+ name: string;
79
+ page: Page;
80
+ params?: EventParams;
81
+ }
82
+ interface Page {
83
+ url: string;
84
+ path: string;
85
+ title?: string;
86
+ /** document.referrer 원문 */
87
+ ref?: string;
88
+ /** SPA 라우트 전환 직전의 논리적 이전 경로 */
89
+ logicalRef?: string;
90
+ /** 페이지 뷰 단위 식별자. `pageId` 사전 계약을 대체한다 */
91
+ viewId: string;
92
+ }
93
+ /**
94
+ * custom event params는 자유 스키마라 타입 제약을 걸지 않는다.
95
+ * 제한은 `limits.ts`의 런타임 상수·검증 유틸로만 표현한다.
96
+ */
97
+ type EventParams = Record<string, unknown>;
98
+ /** 예약 이름의 단일 출처. `limits.ts`의 `RESERVED`가 이 타입에서 파생된다 */
99
+ type ReservedName = 'page_view' | 'click' | 'impression' | 'scroll_depth' | 'runtime_error';
100
+ type LoadType = 'navigate' | 'reload' | 'back_forward';
101
+ type PageViewParams = {
102
+ loadType: LoadType;
103
+ /** foreground 누적 시간(ms). `sessionDuration`과 의미가 달라 합산하지 않는다 */
104
+ engagementTime?: number;
105
+ /** 세션 wall-clock 경과 시간(ms). `engagementTime`과 합산하지 않는다 */
106
+ sessionDuration?: number;
107
+ utm_source?: string;
108
+ utm_medium?: string;
109
+ utm_campaign?: string;
110
+ utm_term?: string;
111
+ utm_content?: string;
112
+ };
113
+ type ClickParams = {
114
+ tag: string;
115
+ id?: string;
116
+ /** 50자 이하로 절삭한다 */
117
+ text?: string;
118
+ href?: string;
119
+ x?: number;
120
+ y?: number;
121
+ section?: string;
122
+ };
123
+ /**
124
+ * 노출 1건. **`ClickParams`와 같은 규칙이다** — 관측된 사실 하나(`itemId`)만 required이고
125
+ * 서술 필드는 전부 optional이다.
126
+ *
127
+ * ⛔ **`ratio`·`duration`을 되살리지 말 것.** 둘은 관측값이 아니라 발화 설정값이라
128
+ * `LogBatch.impressionOpt`(배치당 1회)로 올렸다. 매 이벤트에 같은 상수를 싣던 원래 형태는
129
+ * 정작 「무엇이 노출됐는가」를 담을 자리가 없었다.
130
+ *
131
+ * ⚠️ **대상 1건당 이벤트 1건이다**(B안). `items[]` 배열로 묶지 않는 이유는 값이 전부
132
+ * Primitive여야 적재가 `params`를 컬럼으로 펼치든 map/JSON으로 두든 그대로 유효하기 때문이다.
133
+ */
134
+ type ImpressionParams = {
135
+ /** `ClickParams.section`과 **같은 키**여야 조인해 CTR이 나온다 */
136
+ section?: string;
137
+ itemId: string;
138
+ /** 50자 이하로 절삭한다 — `ClickParams.text`와 같다 */
139
+ itemName?: string;
140
+ itemIndex?: number;
141
+ };
142
+ /** `scroll_depth`의 params. 발화부가 인라인으로 넘기던 것을 `emit()` 경계용으로 이름 붙였다 */
143
+ type ScrollDepthParams = {
144
+ depth: number;
145
+ };
146
+ /**
147
+ * impression 발화 임계값. 세션 전역 설정이며 기본값은 `IMPRESSION_OPT`(`impression.ts`)다.
148
+ */
149
+ interface ImpressionOpt {
150
+ /** 노출로 볼 면적 비율(0~1) */
151
+ threshold: number;
152
+ /** 그 면적을 유지해야 하는 시간(ms) */
153
+ duration: number;
154
+ /** IntersectionObserver의 `rootMargin` */
155
+ rootMargin: string;
156
+ }
157
+ /** 초기화 설정 — zero-config의 실체는 `key` 한 개다 */
158
+ interface InitConfig {
159
+ key: string;
160
+ /** 수집 서버 오리진 또는 상대 경로. 경로는 SDK가 붙이고 호스트가 이 값을 채운다 */
161
+ endpoint: string;
162
+ /** `endpoint` 뒤에 붙는 수집 경로. 생략하면 `LOG_PATH`, 빈 문자열이면 `endpoint`를 그대로 쓴다 */
163
+ path?: string;
164
+ /** impression 발화 임계값 덮어쓰기. 지정한 키만 기본값을 대체한다 */
165
+ impression?: Partial<ImpressionOpt>;
166
+ }
167
+
168
+ declare const BID_NAME = "_bsa_bid";
169
+ declare const SID_NAME = "_bsa_sid";
170
+ declare const IDLE_MAX: number;
171
+ interface IdSnapshot {
172
+ bId?: string;
173
+ sId?: string;
174
+ tabSId: string;
175
+ user?: User;
176
+ consent: Consent;
177
+ startedAt?: number;
178
+ }
179
+
180
+ declare class Tracker {
181
+ private readonly identity;
182
+ private readonly engagement;
183
+ private readonly depth;
184
+ /** 노출 관찰. init 전에는 없다 */
185
+ private impressions;
186
+ private config;
187
+ private env;
188
+ private page;
189
+ private prevUrl;
190
+ private warned;
191
+ private readonly batch;
192
+ private readonly persist;
193
+ /**
194
+ * 배치·전송이 쥐고 있는 이벤트. 보관은 write-ahead라 보관소에는 「아직 전송 차례가 오지
195
+ * 않은 것」과 「전송에 실패해 다시 보내야 하는 것」이 섞여 있는데, 이 표시가 그 둘을 가른다.
196
+ *
197
+ * ⛔ **버퍼를 들여다보는 것으로는 가를 수 없다.** 보관분을 읽는 사이에도 이벤트는 추가되고
198
+ * flush되므로, 목록을 읽기 전에 잡으면 그 사이 추가된 것을, 읽은 뒤에 잡으면 그 사이
199
+ * flush된 것을 놓친다. 소유권은 시점이 아니라 상태여야 한다.
200
+ *
201
+ * 이전 세션의 보관분은 여기에 없다 — 그래서 탭이 죽은 뒤의 복구는 그대로 동작한다.
202
+ */
203
+ private readonly owned;
204
+ private retryTimer;
205
+ private abort;
206
+ private unhook;
207
+ private revived;
208
+ private stopped;
209
+ private snapshot;
210
+ get ready(): boolean;
211
+ init(config?: Partial<InitConfig>): boolean;
212
+ identify(userId: string, props?: User['props']): void;
213
+ setConsent(analytics: boolean): void;
214
+ ids(): IdSnapshot;
215
+ flush(): void;
216
+ destroy(): void;
217
+ events(): Event[];
218
+ queue(): {
219
+ durable: boolean;
220
+ held: number;
221
+ };
222
+ send(name: string, params?: EventParams): void;
223
+ /** ⛔ 예약 이름 판정은 여기서만 한다 — `send()`에 두면 자동 계측 경로가 검사를 건너뛴다 */
224
+ private record;
225
+ /** 자동 계측 전용 경로. 예약 이름을 낼 수 있는 유일한 곳이라 SDK 내부에서만 부른다 */
226
+ private auto;
227
+ private emit;
228
+ /**
229
+ * ⛔ **전송이 시작된 이벤트도 그 전송이 끝날 때까지 소유한다.** 백오프와 `online`이 겹쳐
230
+ * 돌면 재전송이 두 번 시작될 수 있는데, 표시가 없으면 아직 날아가는 중인 보관분을 두
231
+ * 번째 재전송이 다시 집어간다. `record()`가 붙인 표시와 같은 표시다(집합이라 겹쳐도 된다).
232
+ */
233
+ private ship;
234
+ /** ⛔ 쪼갤 수 없는 1건이 상한을 넘으면 드롭한다 — 그대로 보내면 브라우저가 조용히 삼킨다 */
235
+ private deliver;
236
+ /** 배치·전송이 쥐고 있던 이벤트를 놓는다. 놓인 것만 재전송 대상이 된다 */
237
+ private release;
238
+ /**
239
+ * 보관분 재전송.
240
+ *
241
+ * ⛔ **배치 버퍼가 아직 들고 있는 이벤트는 재전송 대상이 아니다.** 보관은 write-ahead라
242
+ * 이벤트가 버퍼에 들어가는 시점에 이미 기록된다 — 걸러내지 않으면 여기서 한 번, 버퍼가
243
+ * 제 차례에 또 한 번 보내 같은 `evtId`가 수집 서버에 두 레코드로 남는다.
244
+ *
245
+ * ⛔ **제외는 꺼내는 쪽인 여기서 한다.** 보관을 늦추면 flush 전에 탭이 죽었을 때 복구되지
246
+ * 않는다 — write-ahead의 목적 자체가 사라진다.
247
+ *
248
+ * ⛔ **소유권 표시로 가른다 — 버퍼를 읽는 시점으로 가르지 않는다.** 보관분을 읽는 사이에도
249
+ * 이벤트는 추가되고 flush되므로 어느 시점에 버퍼를 읽어도 한쪽 창이 남는다(`owned` 참조).
250
+ */
251
+ private retry;
252
+ private scheduleRetry;
253
+ private pageView;
254
+ private click;
255
+ private watch;
256
+ private warn;
257
+ }
258
+
259
+ /** 번들에 심긴 SDK 버전. CDN에 올라간 번들과 npm 버전이 어긋났는지 런타임에 확인한다 */
260
+ declare const VERSION: string;
261
+
262
+ declare const NAME_MAX = 40;
263
+ declare const TEXT_MAX = 50;
264
+ /** DOM 속성에서 원문으로 읽는 값(`click.id`·`section`·`impression.itemId`)의 절삭 길이 */
265
+ declare const ATTR_MAX = 200;
266
+ /** `utm_*` 값의 절삭 길이. GA4의 캠페인 파라미터 상한과 같다 */
267
+ declare const UTM_MAX = 100;
268
+ /** ⛔ `EVENT_MAX` < `BODY_MAX` 관계를 깨지 말 것 — `ship()`의 배치 분할이 수렴하지 않는다 */
269
+ declare const EVENT_MAX: number;
270
+ declare const PARAM_LIMIT: {
271
+ readonly keys: 25;
272
+ readonly keyLength: 40;
273
+ readonly valueLength: 500;
274
+ };
275
+ declare function checkParams(params: EventParams): string[];
276
+ declare function sizeOf(value: unknown): number | null;
277
+ /** ⛔ `runtime_error`는 발화부가 없어도 예약을 유지한다 — 적재 측이 이 이름에 의미를 준다 */
278
+ declare const RESERVED: readonly ReservedName[];
279
+ declare function checkName(name: string): boolean;
280
+
281
+ /**
282
+ * | 코드 | 조건 |
283
+ * | --- | --- |
284
+ * | `E_KEY_MISSING` | `init({ key })`가 없다 |
285
+ * | `E_ENDPOINT_MISSING` | `init({ endpoint })`가 없다 |
286
+ * | `E_ENDPOINT_EMPTY` | endpoint가 빈 값이다 |
287
+ * | `E_ENDPOINT_ILLEGAL_CHAR` | 제어문자 또는 역슬래시가 있다 |
288
+ * | `E_ENDPOINT_QUERY_OR_FRAGMENT` | 쿼리 또는 프래그먼트가 들어 있다 |
289
+ * | `E_ENDPOINT_UNPARSABLE` | URL로 해석할 수 없다 |
290
+ * | `E_ENDPOINT_SCHEME` | http(s)가 아닌 스킴이다 |
291
+ * | `E_ENDPOINT_USERINFO` | userinfo(사용자·비밀번호)가 들어 있다 |
292
+ * | `E_ENDPOINT_CROSS_ORIGIN` | 상대 경로가 다른 오리진으로 해석된다 |
293
+ * | `E_PATH_ILLEGAL_CHAR` | path에 제어문자 또는 역슬래시가 있다 |
294
+ * | `E_PATH_QUERY_OR_FRAGMENT` | path에 쿼리 또는 프래그먼트가 들어 있다 |
295
+ * | `E_PATH_NOT_ABSOLUTE` | 빈 값이 아닌 path가 `/`로 시작하지 않는다 |
296
+ * | `E_PATH_ENCODED` | path에 인코딩된 구분자·퍼센트(`%2F`·`%5C`·`%25`)가 있다 |
297
+ * | `E_PATH_DOT_SEGMENT` | path에 `.`·`..` 세그먼트(퍼센트 인코딩 포함)가 있다 |
298
+ *
299
+ * @module
300
+ */
301
+
302
+ declare const LOG_PATH = "/api/v2/logs/analytics";
303
+ declare function logUrl(endpoint: string, base?: string, path?: string): string;
304
+
305
+ /** 배치 1건의 이벤트 개수 상한 */
306
+ declare const BATCH_MAX = 20;
307
+ /** 시간 임계(ms). 조용한 페이지에서도 이 간격으로 흘려보낸다 */
308
+ declare const FLUSH_WAIT = 5000;
309
+
310
+ declare const BODY_MAX: number;
311
+
312
+ interface Held {
313
+ evtId: string;
314
+ event: Event;
315
+ savedAt: number;
316
+ retryCount: number;
317
+ snapshot?: IdSnapshot;
318
+ /** 보관한 페이지. 없으면 소유자 기록 이전(0.5.2 이하)의 보관분이다 */
319
+ owner?: string;
320
+ }
321
+ declare const MAX_HELD = 500;
322
+ declare const MAX_AGE: number;
323
+ declare const RETRY_MAX = 5;
324
+ declare function trim(records: Held[], now: number): {
325
+ keep: Held[];
326
+ dropped: number;
327
+ };
328
+ declare function backoff(retryCount: number): number;
329
+
330
+ declare const ROUTE_EVENT = "bstage:route";
331
+
332
+ /** 명시 추적 대상 표시. 고객사가 붙인다 */
333
+ declare const CLICK_ATTR = "data-log-click";
334
+ /** 섹션 이름을 자동 추출하는 속성. 가장 가까운 조상에서 찾는다 */
335
+ declare const SECTION_ATTR = "data-log-section";
336
+ /** 고객사가 이벤트 이름을 지정하는 속성. 이것만 있으면 커스텀 이벤트가 발화한다 */
337
+ declare const EVENT_ATTR = "data-log-event";
338
+ /** 커스텀 이벤트에 함께 실을 값. 접두어 뒤가 params 키가 된다 */
339
+ declare const PARAM_PREFIX = "data-log-param-";
340
+ /** `readEvent`가 쓰는 DOM 표면. `Element`가 이 형태를 만족한다 */
341
+ interface EventNode {
342
+ getAttribute(name: string): string | null;
343
+ getAttributeNames(): string[];
344
+ closest(selector: string): EventNode | null;
345
+ }
346
+ /**
347
+ * 고객사가 지정한 이벤트 이름과 값. 이름이 없으면 null이다.
348
+ *
349
+ * ⛔ **이름 규칙·예약 이름 검사를 여기서 하지 않는다.** 결과를 `Tracker.send()`로 넘기면
350
+ * `send()`가 이름 규칙·크기를, `record()`가 예약 이름을 검사한다 — 게스트가 쓴 문자열이
351
+ * 그 두 관문을 반드시 지나야 하므로 `auto()`로 보내면 안 된다.
352
+ */
353
+ declare function readEvent(node: EventNode): {
354
+ name: string;
355
+ params?: EventParams;
356
+ } | null;
357
+
358
+ /** 게스트가 붙이는 속성. `ITEM_ATTR`이 유일한 필수 속성이고 나머지 둘은 선택이다 */
359
+ declare const ITEM_ATTR = "data-log-item";
360
+ declare const ITEM_NAME_ATTR = "data-log-item-name";
361
+ declare const ITEM_INDEX_ATTR = "data-log-item-index";
362
+ /** 이전 구현과 같은 임계값이라 지표를 비교할 수 있다 */
363
+ declare const IMPRESSION_OPT: ImpressionOpt;
364
+ /** `readImpression`이 쓰는 DOM 표면. `Element`가 이 형태를 만족한다 */
365
+ interface ImpressionNode {
366
+ getAttribute(name: string): string | null;
367
+ closest(selector: string): {
368
+ getAttribute(name: string): string | null;
369
+ } | null;
370
+ }
371
+ declare function readImpression(node: ImpressionNode): ImpressionParams | null;
372
+
373
+ declare const DEPTHS: readonly [50, 90];
374
+
375
+ declare const MASK_MAX = 2000;
376
+ declare function mask(text: string): string;
377
+
378
+ declare function init(config?: Parameters<Tracker['init']>[0]): void;
379
+ declare function identify(userId: string, props?: User['props']): void;
380
+ declare function send(name: string, params?: EventParams): void;
381
+ declare function setConsent(analytics: boolean): void;
382
+ declare function ids(): IdSnapshot;
383
+ declare function flush(): void;
384
+ declare function destroy(): void;
385
+ declare function events(): Event[];
386
+ declare function queue(): {
387
+ durable: boolean;
388
+ held: number;
389
+ };
390
+
391
+ export { ATTR_MAX, BATCH_MAX, BID_NAME, BODY_MAX, type Bridge, CLICK_ATTR, type ClickParams, type Consent, DEPTHS, EVENT_ATTR, EVENT_MAX, type Env, type Event, type EventNode, type EventParams, FLUSH_WAIT, type Held, IDLE_MAX, IMPRESSION_OPT, ITEM_ATTR, ITEM_INDEX_ATTR, ITEM_NAME_ATTR, type IdSnapshot, type ImpressionNode, type ImpressionOpt, type ImpressionParams, type InitConfig, LOG_PATH, type LoadType, type LogBatch, MASK_MAX, MAX_AGE, MAX_HELD, NAME_MAX, PARAM_LIMIT, PARAM_PREFIX, type Page, type PageViewParams, RESERVED, RETRY_MAX, ROUTE_EVENT, type ReservedName, SECTION_ATTR, SID_NAME, type ScrollDepthParams, type ServerFields, type Size, TEXT_MAX, UTM_MAX, type User, VERSION, backoff, checkName, checkParams, destroy, events, flush, identify, ids, init, logUrl, mask, queue, readEvent, readImpression, send, setConsent, sizeOf, trim };