// Ghi log hành vi người dùng (phần "đồng hồ người" — thời gian tường). // // Phân vai với backend: ở đây CHỈ ghi thao tác của người. Thời gian máy chạy do // backend tự ghi trong JobManager (job_queued/job_started/job_done) vì chỉ máy // chủ biết job rời hàng đợi lúc nào — frontend không bắn job_done, tránh đếm đôi. // // Bốn tầng định danh: user_id > session_id > task_id > step. // session_id: một lần dùng app; im lặng quá 30' thì mở phiên mới. // task_id : một lần làm ra một sản phẩm. Mở khi mở tool, đóng khi có đầu ra // hoặc bỏ dở. Chạy lại/regenerate KHÔNG mở task mới, chỉ attempt+1. // // Log gom thành lô, gửi mỗi 5 giây; lúc đóng tab dùng sendBeacon để không mất // dòng feature_abandon. const ENDPOINT = "/api/events"; const FLUSH_MS = 5000; const MAX_BATCH = 20; const IDLE_MS = 30 * 60 * 1000; // ngưỡng im lặng -> hết phiên (spec §6) const STEPS = ["open", "prepare", "run", "review", "output", "done"]; // Đường dẫn -> khoá tính năng (feature) dùng trong log. export const ROUTE_FEATURE = { "/": "home", "/down-list": "down_list", "/cut": "cut", "/xao": "shuffle", "/dub": "dub", "/chen-chu": "text_video", "/listing-anh": "listing_image", "/listing-free": "listing_free", "/flux2": "flux2", "/tao-video": "create_video", "/huong-dan": "guide", "/ho-tro": "support", "/quan-tri": "admin", "/lich-su": "history", }; // Trang chỉ để XEM, không làm ra sản phẩm. Ghi `page_view` KHÔNG kèm task_id: // mở trang chủ/hướng dẫn mà tính là một "task 0% hoàn thành" thì bảng theo tool // và tỷ lệ A3 bị pha loãng bằng thứ không phải công việc. const PASSIVE = new Set(["home", "guide", "support", "admin", "history"]); const rid = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); const nowIso = () => { const d = new Date(); const off = -d.getTimezoneOffset(); const sign = off >= 0 ? "+" : "-"; const p = (n, w = 2) => String(Math.abs(n)).padStart(w, "0"); return ( `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + `T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}` + `${sign}${p(off / 60 | 0)}:${p(off % 60)}` ); }; const ls = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch { /* chế độ riêng tư */ } }, }; const ss = { get(k) { try { return sessionStorage.getItem(k); } catch { return null; } }, set(k, v) { try { sessionStorage.setItem(k, v); } catch { /* noop */ } }, del(k) { try { sessionStorage.removeItem(k); } catch { /* noop */ } }, }; // ── Trạng thái phiên / task ───────────────────────────────────── let queue = []; let timer = null; let session = ""; let task = null; // { id, feature, startedAt, lastStep, closed, attempt, phase } let started = false; let lastOpen = { feature: "", ts: 0 }; // chặn mở trùng (xem featureOpen) function touch() { ls.set("tnt_track_last", String(Date.now())); } function sessionId() { if (session) return session; const last = Number(ls.get("tnt_track_last") || 0); const prev = ls.get("tnt_track_sess") || ""; if (prev && last && Date.now() - last < IDLE_MS) { session = prev; // quay lại trong 30' -> vẫn phiên cũ } else { session = mkId("s"); ls.set("tnt_track_sess", session); started = false; } return session; } function userId() { // Ưu tiên mã U00N (do backend cấp); chưa có thì lùi về tên đăng nhập. return (ls.get("tnt_uid") || ls.get("tnt_user") || "").trim().toLowerCase(); } // Mảnh định danh an toàn cho ID (bỏ dấu cách/ký tự lạ, cắt ngắn). function idSafe(x) { return (x || "").toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 12) || "anon"; } // task_id / session_id KIỂU ĐỌC ĐƯỢC, tự sắp theo thời gian, không cần bộ đếm: // ____ // Dùng giờ+random thay số thứ tự trong ngày -> không đụng nhau khi mở 2 tab/máy. function mkId(prefix) { const d = new Date(); const p = (n, w = 2) => String(n).padStart(w, "0"); const date = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`; const time = `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; const rnd = Math.random().toString(36).slice(2, 6); return `${prefix}_${idSafe(userId())}_${date}_${time}_${rnd}`; } // ── Hàng đợi gửi ──────────────────────────────────────────────── function push(row) { const user = userId(); if (!user) return; // chưa đăng nhập -> không có gì để quy về ai queue.push({ event_id: "evt_" + rid(), event_time: nowIso(), user_id: user, ...row }); touch(); if (queue.length >= MAX_BATCH) flush(); else if (!timer) timer = setTimeout(flush, FLUSH_MS); } export function flush(useBeacon = false) { if (timer) { clearTimeout(timer); timer = null; } if (!queue.length) return; const body = JSON.stringify({ events: queue }); queue = []; try { if (useBeacon && navigator.sendBeacon) { navigator.sendBeacon(ENDPOINT, new Blob([body], { type: "application/json" })); return; } fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, }).catch(() => {}); } catch { /* mất log còn hơn vỡ app */ } } // ── Ghi event ─────────────────────────────────────────────────── function emit(event_name, step, props = {}, event_value = "") { push({ session_id: sessionId(), task_id: task ? task.id : "", feature: task ? task.feature : "", step: STEPS.includes(step) ? step : "prepare", event_name, event_value: event_value == null ? "" : String(event_value), properties: props, }); if (task && STEPS.includes(step) && step !== "done") task.lastStep = step; } /** Mở app / đăng nhập xong. Gọi một lần khi vào Layout. */ export function sessionStart(props = {}) { sessionId(); if (started) return; started = true; // Cờ `started` nằm trong bộ nhớ nên F5 là mất, trong khi session_id vẫn giữ // trong localStorage -> không chốt lại thì mỗi lần tải lại trang đẻ thêm một // dòng session_start cho cùng một phiên. if (ls.get("tnt_track_started") === session) return; ls.set("tnt_track_started", session); push({ session_id: session, task_id: "", feature: "", step: "open", event_name: "session_start", event_value: "", properties: { client: "web", ...props }, }); } /** Mở một tool -> đóng task cũ (nếu dở) và mở task mới. */ export function featureOpen(feature, props = {}) { if (!feature) return; // Chống mở TRÙNG: React.StrictMode (dev) chạy effect hai lần, và một lần // re-render lạc cũng gọi lại. Không chặn thì mỗi lần mở tool đẻ thêm một task // rỗng kèm feature_abandon dài 1ms -> tỷ lệ "làm xong" tụt oan. if (Date.now() - lastOpen.ts < 2000 && lastOpen.feature === feature) return; lastOpen = { feature, ts: Date.now() }; abandonIfOpen(); if (PASSIVE.has(feature)) { task = null; // trang xem: không mở task push({ session_id: sessionId(), task_id: "", feature, step: "open", event_name: "page_view", event_value: "", properties: props, }); return; } task = { id: mkId(idSafe(feature)), feature, startedAt: Date.now(), lastStep: "open", closed: false, attempt: 0, phase: "", }; // source/parent_task: task mới sinh ra từ đâu (rerun/forward/history). // Forward: tool nguồn đã gửi kèm "cha" qua sessionStorage trước khi chuyển // trang -> nhặt lại ở đây cho task của tool đích. let extra = props; try { const fwd = JSON.parse(ss.get("tnt_fwd") || "null"); if (fwd && Date.now() - fwd.ts < 60000 && (!fwd.to || fwd.to.includes(feature)) && !props.source) { extra = { source: "forward", parent_task: fwd.parent, ...props }; } ss.del("tnt_fwd"); } catch { /* noop */ } emit("feature_open", "open", extra); } /** Một thao tác thường của người (dán link, tải file lên, sửa lời...). */ export function step(event_name, stepName = "prepare", props = {}, value = "") { if (!task) return; // Task đã có đầu ra mà người dùng làm tiếp -> đó là sản phẩm mới -> task mới. if (task.closed) featureOpen(task.feature, { from: "continue" }); emit(event_name, stepName, props, value); } /** Người bấm nút chạy máy. Tăng attempt (A4) — backend đọc qua header. */ export function runClick(props = {}) { if (!task) return; if (task.closed) featureOpen(task.feature, { from: "continue" }); task.attempt += 1; // Nhớ tên pha máy sắp chạy (asr_translate / tts / burn / cut…) và gửi kèm // header, để dòng job_done do backend ghi biết mình là bước nào -> chỉ số B2 // tách được tốc độ từng bước thay vì gộp một cục. if (props.phase) task.phase = props.phase; emit("run_click", "run", { attempt: task.attempt, ...props }); return task.attempt; } /** Làm lại (Tạo lại / Yêu cầu sửa) — cùng task, attempt+1. */ export function retry(event_name = "regenerate", props = {}) { if (!task) return; task.attempt += 1; emit(event_name, "run", { attempt: task.attempt, ...props }); } /** Có sản phẩm: tải về hoặc chuyển tiếp sang tool khác. Cả hai đều là XONG. */ export function output(kind = "file", props = {}, forwardTo = "") { if (!task) return; const isForward = !!forwardTo; if (isForward) { ss.set("tnt_fwd", JSON.stringify({ parent: task.id, to: forwardTo, ts: Date.now() })); } emit( isForward ? "output_forward" : "output_download", "output", { success_type: isForward ? "forward" : "download", ...(isForward ? { to: forwardTo } : {}), ...props }, isForward ? forwardTo : kind, ); if (!task.closed) { emit("feature_complete", "done", { success_type: isForward ? "forward" : "download", dur_ms: Date.now() - task.startedAt, attempts: task.attempt, }); task.closed = true; } } /** Nút 👍/👎 ở chỗ hiện kết quả (chỉ số B3). */ export function thumbs(value, props = {}) { if (!task) return; emit("thumbs_feedback", task.closed ? "output" : "review", props, value === "up" ? "up" : "down"); } /** Nút "Làm mới / Bắt đầu lại" -> đóng task hiện tại, mở task mới. */ export function restart(props = {}) { if (!task) return; const f = task.feature; const parent = task.id; // task đang đóng lại chính là "cha" featureOpen(f, { source: "rerun", parent_task: parent, ...props }); } function abandonIfOpen(extra = {}) { if (!task || task.closed) return; emit("feature_abandon", task.lastStep || "open", { last_step: task.lastStep || "open", dur_ms: Date.now() - task.startedAt, attempts: task.attempt, ...extra, }); task.closed = true; } /** Bỏ dở có lý do rõ (vd thiếu API key). */ export function abandon(reason = "", lastStep = "") { if (!task || task.closed) return; if (lastStep) task.lastStep = lastStep; abandonIfOpen(reason ? { reason } : {}); } /** Ngữ cảnh hiện tại — dùng để gắn header X-TNT-* vào request API. */ export function currentCtx() { return { user_id: userId(), session_id: session || sessionId(), task_id: task ? task.id : "", feature: task ? task.feature : "", phase: task ? task.phase || "" : "", attempt: task ? task.attempt : 0, }; } // ── Gắn ngữ cảnh vào MỌI request /api/ ────────────────────────── // // Vá `fetch` một lần ở đây thay vì sửa ~15 chỗ gọi API: backend đọc header // X-TNT-* trong middleware rồi tự gắn vào job, nên dòng log "máy chạy" biết // thuộc task nào của ai mà không phải đổi model request nào cả. let patched = false; function patchFetch() { if (patched || typeof window === "undefined") return; patched = true; const orig = window.fetch.bind(window); window.fetch = (input, init = {}) => { try { const url = typeof input === "string" ? input : (input && input.url) || ""; const isApi = url.startsWith("/api/") || url.includes("/api/"); if (isApi && !url.includes("/api/events")) { const c = currentCtx(); if (c.user_id) { const h = new Headers((init && init.headers) || (input instanceof Request ? input.headers : undefined)); h.set("X-TNT-User", c.user_id); h.set("X-TNT-Session", c.session_id); if (c.task_id) h.set("X-TNT-Task", c.task_id); if (c.feature) h.set("X-TNT-Feature", c.feature); if (c.phase) h.set("X-TNT-Phase", c.phase); if (c.attempt) h.set("X-TNT-Attempt", String(c.attempt)); return orig(input, { ...init, headers: h }); } } } catch { /* vá lỗi thì gọi như cũ */ } return orig(input, init); }; } let inited = false; /** Gọi một lần lúc app khởi động (trong Layout). */ export function initTracking() { if (inited || typeof window === "undefined") return; inited = true; patchFetch(); sessionStart(); const bye = () => { abandonIfOpen({ reason: "close_tab" }); flush(true); }; window.addEventListener("pagehide", bye); window.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flush(true); }); setInterval(() => flush(), FLUSH_MS * 3); } export default { initTracking, sessionStart, featureOpen, step, runClick, retry, output, thumbs, restart, abandon, flush, currentCtx, ROUTE_FEATURE, };