titusgiap's picture
Claude Opus 4.8
feat(analytics): mã người dùng U00N + task_id/session_id đọc-được + source/parent_task
5edb87f
Raw History Blame Contribute Delete
14.3 kB
// 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:
// <prefix>_<user>_<YYYYMMDD>_<HHMMSS>_<rand4>
// 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,
};