@e3i3/klicklab-esm-sdk
Version:
SDK for klicklab service user_엔드포인트 변경
645 lines (585 loc) • 19.1 kB
JavaScript
// klicklab-sdk.esm.js - ESM 방식으로 변환된 KlickLab SDK
const API_BASE_URL =
"http://klicklab-nlb-0f6efee8fd967688.elb.ap-northeast-2.amazonaws.com";
const COLLECT_URL =
"http://klicklab-nlb-0f6efee8fd967688.elb.ap-northeast-2.amazonaws.com/api/analytics/collect";
function generateUUID() {
// UUID V4 규격 따름. 3블록 첫자리에 4, y 자리에 8-11 숫자중 하나.
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function evaluateEventRule(rule, event) {
const { condition_type, condition_parameter, condition_value } = rule;
const eventParamValue = event.properties[condition_parameter];
if (typeof eventParamValue === "undefined" || eventParamValue === null)
return false;
switch (condition_type) {
case "url_contains":
return String(eventParamValue).includes(condition_value);
case "url_equals":
return String(eventParamValue) === condition_value;
case "form_id_equals":
return event.properties.form_id === condition_value;
default:
return false;
}
}
const SDK = {};
SDK.getClientId = function () {
let clientId = localStorage.getItem("analytics_client_id");
if (!clientId) {
clientId = generateUUID();
localStorage.setItem("analytics_client_id", clientId);
}
return clientId;
};
SDK.getSessionId = function () {
let sessionId = sessionStorage.getItem("analytics_session_id");
if (!sessionId) {
sessionId =
"sess_" + new Date().toISOString() + "_" + generateUUID().split("-")[0];
sessionStorage.setItem("analytics_session_id", sessionId);
// 세션이 새로 시작될 때 session_start 이벤트 자동 전송
setTimeout(() => {
SDK.sendEvent("session_start", {
session_id: sessionId,
page_path: window.location.pathname,
page_title: document.title,
referrer: document.referrer,
});
}, 0);
}
return sessionId;
};
// 위치정보 수집 함수 추가
SDK.getLocationInfo = function () {
return new Promise((resolve) => {
// 브라우저 Geolocation API 사용
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
source: "gps",
});
},
(error) => {
console.log("⚠️ GPS 위치정보 수집 실패:", error.message);
// GPS 실패 시 IP 기반 위치정보로 fallback
SDK.getIPLocationInfo().then(resolve);
},
{
timeout: 5000,
maximumAge: 300000, // 5분간 캐시
enableHighAccuracy: false,
}
);
} else {
// Geolocation API 미지원 시 IP 기반으로 fallback
SDK.getIPLocationInfo().then(resolve);
}
});
};
// IP 기반 위치정보 수집 함수
SDK.getIPLocationInfo = function () {
return fetch("https://ipapi.co/json/")
.then((response) => response.json())
.then((data) => ({
country: data.country_code,
city: data.city,
region: data.region,
timezone: data.timezone || "Asia/Seoul",
ip: data.ip,
latitude: data.latitude,
longitude: data.longitude,
source: "ip",
}))
.catch((error) => {
console.log("⚠️ IP 위치정보 수집 실패:", error);
return {
country: null,
city: null,
region: null,
timezone: "Asia/Seoul",
ip: null,
latitude: null,
longitude: null,
source: "none",
};
});
};
SDK.getDeviceInfo = function () {
const ua = navigator.userAgent;
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(ua);
let os = "Unknown";
if (/Android/i.test(ua)) {
os = "Android";
} else if (/iPhone|iPad|iPod/i.test(ua)) {
os = "iOS";
} else if (/Macintosh|Mac OS X/i.test(ua)) {
os = "macOS";
} else if (/Windows/i.test(ua)) {
os = "Windows";
}
const device_type = isMobile
? "mobile"
: os === "macOS" || os === "Windows"
? "desktop"
: "unknown";
const browser = /Chrome/.test(ua)
? "Chrome"
: /Safari/.test(ua)
? "Safari"
: /Firefox/.test(ua)
? "Firefox"
: "Other";
return {
device_type,
os,
browser,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
};
SDK.parseTrafficSource = function () {
const params = new URLSearchParams(window.location.search);
return {
traffic_medium: params.get("utm_medium") || "direct",
traffic_source: params.get("utm_source") || document.referrer || "direct",
traffic_campaign: params.get("utm_campaign"),
};
};
SDK.getUserId = function () {
if (window.__REDUX_STORE__) {
const state = window.__REDUX_STORE__.getState();
if (state.auth && state.auth.id) {
return state.auth.id;
}
}
const userData = localStorage.getItem("user_data");
if (userData) {
try {
const user = JSON.parse(userData);
return user.id || null;
} catch (e) {
return null;
}
}
return null;
};
SDK.getUserGender = function () {
const userData = localStorage.getItem("user_data");
if (userData) {
try {
const user = JSON.parse(userData);
return user.gender || null;
} catch (e) {
return null;
}
}
return null;
};
SDK.getUserAge = function () {
const userData = localStorage.getItem("user_data");
if (userData) {
try {
const user = JSON.parse(userData);
return user.age || null;
} catch (e) {
return null;
}
}
return null;
};
SDK.getElementPath = function (element) {
if (!element) return null;
const path = [];
let current = element;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += "#" + current.id;
} else if (current.className) {
const classes = current.className.split(" ").filter((c) => c.trim());
if (classes.length > 0) selector += "." + classes.join(".");
}
const siblings = Array.from(current.parentNode.children).filter(
(child) => child.tagName === current.tagName
);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
selector += `:nth-child(${index})`;
}
path.unshift(selector);
current = current.parentNode;
}
return path.join(" > ");
};
// --- 이벤트 전송 로직 ---
// 위치정보 캐시 변수
let cachedLocationInfo = null;
let locationInfoPromise = null;
// 1. 서버로 실제 전송을 담당하는 내부 함수
const _sendToServer = async function (
eventName,
properties = {},
element = null,
userId = null,
userGender = null,
userAge = null
) {
if (!window.KLICKLAB_SDK_KEY) {
return console.warn("KlickLab SDK 키가 설정되지 않았습니다.");
}
const deviceInfo = SDK.getDeviceInfo();
const trafficInfo = SDK.parseTrafficSource();
const finalUserId = userId !== null ? userId : SDK.getUserId();
const finalUserGender =
userGender !== null ? userGender : SDK.getUserGender();
const finalUserAge = userAge !== null ? userAge : SDK.getUserAge();
const elementInfo = element
? {
element_path: SDK.getElementPath(element),
element_tag: element.tagName,
element_id: element.id,
element_class: element.className,
}
: {};
// 위치정보 수집 (캐시된 정보가 있으면 사용, 없으면 새로 수집)
let locationInfo = cachedLocationInfo;
if (!locationInfo) {
if (!locationInfoPromise) {
locationInfoPromise = SDK.getLocationInfo();
}
try {
locationInfo = await locationInfoPromise;
cachedLocationInfo = locationInfo;
// 5분 후 캐시 무효화
setTimeout(() => {
cachedLocationInfo = null;
locationInfoPromise = null;
}, 300000);
} catch (error) {
console.log("⚠️ 위치정보 수집 실패:", error);
locationInfo = {
country: null,
city: null,
region: null,
timezone: "Asia/Seoul",
latitude: null,
longitude: null,
source: "none",
};
}
}
const eventData = {
sdk_key: window.KLICKLAB_SDK_KEY,
event_name: eventName,
timestamp: new Date().toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }),
client_id: SDK.getClientId(),
user_id: finalUserId,
session_id: SDK.getSessionId(),
user_gender: finalUserGender,
user_age: finalUserAge,
device_type: deviceInfo.device_type,
traffic_medium: trafficInfo.traffic_medium,
traffic_source: trafficInfo.traffic_source,
traffic_campaign: trafficInfo.traffic_campaign,
// 위치정보 추가
country: locationInfo.country,
city: locationInfo.city,
region: locationInfo.region,
latitude: locationInfo.latitude,
longitude: locationInfo.longitude,
location_source: locationInfo.source,
properties: {
page_path:
window.location.pathname + // "/demo.html"
window.location.search + // "?page=product"
window.location.hash, // "#/order/success"
page_title: document.title,
referrer: document.referrer,
...properties,
},
context: {
geo: {
country: locationInfo.country,
city: locationInfo.city,
region: locationInfo.region,
latitude: locationInfo.latitude,
longitude: locationInfo.longitude,
timezone:
locationInfo.timezone ||
Intl.DateTimeFormat().resolvedOptions().timeZone,
source: locationInfo.source,
},
device: deviceInfo,
traffic_source: trafficInfo,
user_agent: navigator.userAgent,
screen_resolution: `${screen.width}x${screen.height}`,
viewport_size: `${window.innerWidth}x${window.innerHeight}`,
utm_params: Object.fromEntries(
new URLSearchParams(window.location.search)
),
},
...elementInfo,
};
console.log("🌍 위치정보 포함 이벤트 전송:", eventData);
fetch(COLLECT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(eventData),
keepalive: true,
}).catch((err) => console.error(`Event sending failed:`, err));
};
// 2. 규칙을 적용하고 전송을 결정하는 공개 게이트웨이 함수
SDK.sendEvent = async function (
eventName,
properties = {},
userId = null,
userGender = null,
userAge = null,
element = null
) {
await _sendToServer(
eventName,
properties,
element,
userId,
userGender,
userAge
);
if (eventName === "page_view" || eventName === "form_submit") {
const eventRules = window.KLICKLAB_EVENT_RULES || [];
for (const rule of eventRules) {
if (
rule.source_event_name === eventName &&
evaluateEventRule(rule, { properties })
) {
const transformedEventName = rule.new_event_name;
// 추가 필드 없이 그대로 전송
await _sendToServer(
transformedEventName,
properties,
element,
userId,
userGender,
userAge
);
break;
}
}
}
};
// --- 자동 추적 및 초기화 (이벤트 위임 적용) ---
// 3. 페이지의 모든 클릭을 감지하고 규칙과 일치하는지 확인하는 '통합 리스너'
async function masterClickListener(event) {
// 클릭된 대상이 Element 가 아니라면 부모 Element 로 보정
let clickedElement = event.target;
if (!(clickedElement instanceof Element)) {
clickedElement = clickedElement.parentElement;
}
if (!clickedElement) return;
// kl-disabled 클래스가 붙은 요소는 수집하지 않음
if (clickedElement.closest(".kl-disabled")) {
return;
}
// 더 풍부한 클릭 정보 수집
await _sendToServer(
"auto_click",
{
click_x: event.clientX,
click_y: event.clientY,
page_x: event.pageX,
page_y: event.pageY,
target_text: clickedElement.textContent?.trim(),
target_tag: clickedElement.tagName,
target_class: clickedElement.className,
target_id: clickedElement.id,
target_href: clickedElement.href || null,
is_button: clickedElement.tagName === "BUTTON",
is_link: clickedElement.tagName === "A",
},
clickedElement
);
console.log("🔍 buttonRules loaded:", window.KLICKLAB_BUTTON_EVENT_RULES);
// 버튼 클릭 규칙 확인
const buttonRules = window.KLICKLAB_BUTTON_EVENT_RULES || [];
for (const rule of buttonRules) {
console.log(
"… testing selector",
rule.css_selector,
"against element",
clickedElement
);
const matchedElement = clickedElement.closest(rule.css_selector);
console.log("… closest returned:", matchedElement);
if (matchedElement) {
console.log("✅ selector matched! sending event:", rule.event_name);
await _sendToServer(
rule.event_name,
{
element_text: matchedElement.textContent?.trim() || null,
},
matchedElement
);
break; // 규칙 찾으면 종료
}
}
}
const SENSITIVE_FIELDS = new Set([
"password",
"confirmPw",
"currentPw",
"newPw",
"creditCard",
"cvv",
"ssn", // 추가 민감 필드
]);
SDK.autoTrackForms = function () {
document.addEventListener("submit", function (e) {
const form = e.target;
// kl-disabled 클래스가 붙어 있으면 수집 스킵
if (form instanceof Element && form.closest(".kl-disabled")) {
return;
}
const formData = new FormData(form);
const formFields = {};
for (let [key, value] of formData.entries()) {
if (!SENSITIVE_FIELDS.has(key.toLowerCase())) {
formFields[key] = value;
}
}
SDK.sendEvent(
"form_submit",
{
form_action: form.action,
form_method: form.method,
form_id: form.id,
form_class: form.className,
form_fields: formFields,
form_field_count: Object.keys(formFields).length,
},
null,
form
);
});
};
SDK.init = async function () {
// 1. 이미 설정된 키가 있으면 우선 사용
let sdkKey = window.KLICKLAB_SDK_KEY;
// 2. 설정된 키가 없으면 script 태그에서 찾기
if (!sdkKey) {
const scriptTag =
document.currentScript || document.querySelector("script[data-sdk-key]");
sdkKey = scriptTag ? scriptTag.getAttribute("data-sdk-key") : null;
}
// 3. 여전히 키가 없으면 경고
if (!sdkKey) {
return console.warn("KlickLab SDK 키가 설정되지 않았습니다.");
}
// 4. 키 설정
window.KLICKLAB_SDK_KEY = sdkKey;
// 기본값으로 빈 배열 설정
window.KLICKLAB_EVENT_RULES = [];
window.KLICKLAB_BUTTON_EVENT_RULES = [];
// 규칙 로드 시도 (실패해도 SDK는 정상 동작)
try {
const response = await fetch(
`${API_BASE_URL}/api/sdk/rules?sdk_key=${sdkKey}`
);
if (response.ok) {
const data = await response.json();
window.KLICKLAB_EVENT_RULES = data.eventRules || [];
window.KLICKLAB_BUTTON_EVENT_RULES = data.eventbutton || [];
console.log("✅ SDK 규칙 로드 성공:", {
eventRules: window.KLICKLAB_EVENT_RULES.length,
buttonEventRules: window.KLICKLAB_BUTTON_EVENT_RULES.length,
});
} else {
console.log("⚠️ 규칙 서버 응답 오류, 기본 추적만 사용");
}
} catch (error) {
console.log("⚠️ 규칙 로드 실패, 기본 추적만 사용:", error.message);
}
// '이벤트 위임'을 위한 단일 클릭 리스너를 document에 부착합니다.
document.removeEventListener("click", masterClickListener); // 중복 부착 방지
document.addEventListener("click", masterClickListener);
// 나머지 자동 추적 기능을 활성화합니다.
SDK.autoTrackForms();
SDK.trackTimeOnPage();
SDK.trackScrollDepth();
trackPageView();
console.log("✅ KlickLab SDK 초기화 완료 - SDK Key:", sdkKey);
};
SDK.trackTimeOnPage = function () {
const start = Date.now();
window.addEventListener("beforeunload", () => {
const seconds = Math.round((Date.now() - start) / 1000);
SDK.sendEvent("page_exit", {
time_on_page_seconds: seconds,
page_path: window.location.pathname,
});
});
};
SDK.trackScrollDepth = function () {
let maxScroll = 0;
const steps = [25, 50, 75, 90, 100];
const sent = new Set();
let scrollTimer;
window.addEventListener("scroll", () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight =
document.documentElement.scrollHeight - window.innerHeight;
const percent = Math.round((scrollTop / scrollHeight) * 100);
if (percent > maxScroll) maxScroll = percent;
steps.forEach((threshold) => {
if (percent >= threshold && !sent.has(threshold)) {
sent.add(threshold);
SDK.sendEvent("scroll_depth", {
scroll_percentage: threshold,
max_scroll_percentage: maxScroll,
page_path: window.location.pathname,
});
}
});
}, 150); // 150ms 디바운스
});
};
// GA 방식: page_view 이벤트만 집계, fromPage 개념 제거
async function trackPageView() {
await SDK.sendEvent("page_view", {
page_path:
window.location.pathname + window.location.search + window.location.hash,
page_title: document.title,
referrer: document.referrer,
});
}
// 브라우저 환경에서 자동 초기화
if (typeof window !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
SDK.init();
});
// SPA 라우팅 지원: pushState, replaceState, popstate 감지
const _pushState = history.pushState;
const _replaceState = history.replaceState;
history.pushState = function () {
_pushState.apply(this, arguments);
setTimeout(trackPageView, 0);
};
history.replaceState = function () {
_replaceState.apply(this, arguments);
setTimeout(trackPageView, 0);
};
window.addEventListener("popstate", () => {
setTimeout(trackPageView, 0);
});
}
export default SDK;