Files
pagodera/lib/telemetry/MarketTelemetry.ts
Evandro Fonseca Junior 6adc8d96e8 refactor: reorganize hooks and add safety checks
fix(api): add validation for listing and seller in bid route
feat(telemetry): add userId to MarketTelemetry
style(globe): add null check for canvas element
chore: add lint:fix script to package.json
2025-11-11 03:20:01 -03:00

362 lines
8.3 KiB
TypeScript

/**
* Classe para tracking de telemetria no marketplace
* Gerencia eventos client-side com debouncing e batch sending
*/
type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "purchase_intention_created"
| "purchase_intention_viewed"
| "bid_placed"
| "bid_viewed"
| "seller_profile_viewed"
| "inquiry"
| "page_view"
| "price_change";
interface TelemetryEvent {
event_type: TelemetryEventType;
ad_id?: number | null;
session_id?: string;
metadata?: Record<string, any>;
}
interface PendingEvent extends TelemetryEvent {
timestamp: number;
}
export class MarketTelemetry {
private sessionId: string;
private userId?: number;
private eventQueue: PendingEvent[] = [];
private viewStartTimes: Map<number, number> = new Map();
private batchInterval: NodeJS.Timeout | null = null;
private readonly BATCH_INTERVAL_MS = 30000; // 30 segundos
private readonly MAX_BATCH_SIZE = 100;
constructor(userId?: number) {
this.userId = userId;
this.sessionId = this.generateSessionId();
this.startBatchInterval();
this.setupPageUnloadHandler();
}
/**
* Gera um ID de sessão único
*/
private generateSessionId(): string {
if (typeof window === "undefined") return "";
// Tentar recuperar da sessionStorage
let sessionId = sessionStorage.getItem("telemetry_session_id");
if (sessionId) return sessionId;
// Gerar novo ID
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem("telemetry_session_id", sessionId);
return sessionId;
}
/**
* Inicia tracking de visualização de anúncio
*/
trackAdView(adId: number): void {
if (typeof window === "undefined") return;
const startTime = Date.now();
this.viewStartTimes.set(adId, startTime);
this.queueEvent({
event_type: "ad_view",
ad_id: adId,
metadata: {
timestamp: startTime,
},
});
}
/**
* Finaliza tracking de visualização com duração
*/
trackAdExit(adId: number): void {
if (typeof window === "undefined") return;
const startTime = this.viewStartTimes.get(adId);
if (!startTime) return;
const duration = (Date.now() - startTime) / 1000; // em segundos
this.viewStartTimes.delete(adId);
this.queueEvent({
event_type: "ad_exit",
ad_id: adId,
metadata: {
duration,
timestamp: Date.now(),
},
});
}
/**
* Registra clique em anúncio
*/
trackAdClick(adId: number, position?: number): void {
this.queueEvent({
event_type: "ad_click",
ad_id: adId,
metadata: {
position,
timestamp: Date.now(),
},
});
}
/**
* Registra busca
*/
trackSearch(query: string, filters?: Record<string, any>): void {
this.queueEvent({
event_type: "search",
metadata: {
query,
filters,
timestamp: Date.now(),
},
});
}
/**
* Registra aplicação de filtros
*/
trackFilterApplied(filters: Record<string, any>): void {
this.queueEvent({
event_type: "filter",
metadata: {
filters,
timestamp: Date.now(),
},
});
}
/**
* Registra favoritar/desfavoritar
*/
trackFavorite(adId: number, action: "favorite" | "unfavorite"): void {
this.queueEvent({
event_type: action,
ad_id: adId,
metadata: {
timestamp: Date.now(),
},
});
}
/**
* Registra compra
*/
trackPurchase(adId: number, price: number): void {
this.queueEvent({
event_type: "purchase",
ad_id: adId,
metadata: {
price,
timestamp: Date.now(),
},
});
// Enviar imediatamente para eventos críticos
this.flushQueue();
}
/**
* Registra inquiry (mensagem ao vendedor)
*/
trackInquiry(adId: number): void {
this.queueEvent({
event_type: "inquiry",
ad_id: adId,
metadata: {
timestamp: Date.now(),
},
});
}
/**
* Registra visualização de página
*/
trackPageView(page: string): void {
this.queueEvent({
event_type: "page_view",
metadata: {
page,
timestamp: Date.now(),
},
});
}
/**
* Registra evento customizado (para eventos que não têm método específico)
*/
trackCustomEvent(
eventType: TelemetryEventType | string,
adId?: number,
metadata?: Record<string, any>,
): void {
this.queueEvent({
event_type: eventType as TelemetryEventType,
ad_id: adId,
metadata: {
...metadata,
timestamp: Date.now(),
},
});
}
/**
* Adiciona evento à fila
*/
private queueEvent(event: TelemetryEvent): void {
if (typeof window === "undefined") return;
this.eventQueue.push({
...event,
session_id: this.sessionId,
timestamp: Date.now(),
});
// Enviar imediatamente se a fila estiver cheia
if (this.eventQueue.length >= this.MAX_BATCH_SIZE) {
this.flushQueue();
}
}
/**
* Envia eventos em batch para o servidor
*/
async flushQueue(): Promise<void> {
if (this.eventQueue.length === 0) return;
const eventsToSend = [...this.eventQueue];
this.eventQueue = [];
try {
const response = await fetch("/api/telemetry", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
events: eventsToSend.map(({ timestamp, ...event }) => ({
...event,
session_id: this.sessionId,
})),
}),
});
if (!response.ok) {
// Re-adicionar eventos à fila se falhar (mas não logar erro para não poluir console)
this.eventQueue.unshift(...eventsToSend);
// Silenciar erro para não gerar mensagens no console
}
} catch (_error) {
// Re-adicionar eventos à fila se falhar (mas não logar erro)
this.eventQueue.unshift(...eventsToSend);
// Silenciar erro para não gerar mensagens no console
}
}
/**
* Inicia intervalo de envio automático
*/
private startBatchInterval(): void {
if (typeof window === "undefined") return;
this.batchInterval = setInterval(() => {
this.flushQueue();
}, this.BATCH_INTERVAL_MS);
}
/**
* Para o intervalo de envio automático
*/
stop(): void {
if (this.batchInterval) {
clearInterval(this.batchInterval);
this.batchInterval = null;
}
// Enviar eventos pendentes antes de parar
this.flushQueue();
}
/**
* Configura handler para enviar eventos antes de sair da página
*/
private setupPageUnloadHandler(): void {
if (typeof window === "undefined") return;
// Navigator.sendBeacon é mais confiável para page unload
window.addEventListener("beforeunload", () => {
if (this.eventQueue.length === 0) return;
try {
const eventsToSend = this.eventQueue.map(
({ timestamp, ...event }) => event,
);
// Usar sendBeacon para garantir envio
const eventsWithSession = eventsToSend.map((event) => ({
...event,
session_id: this.sessionId,
}));
// Usar Blob para evitar problemas de message channel
const blob = new Blob([JSON.stringify({ events: eventsWithSession })], {
type: "application/json",
});
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/telemetry", blob);
}
} catch (_error) {
// Silenciar erro para não gerar mensagens no console
}
});
// Fallback para visibilitychange
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
this.flushQueue();
}
});
}
/**
* Atualiza user_id (útil após login)
*/
setUserId(userId: number | undefined): void {
this.userId = userId;
}
}
// Instância singleton
let telemetryInstance: MarketTelemetry | null = null;
/**
* Obtém ou cria instância singleton de telemetria
*/
export function getTelemetry(userId?: number): MarketTelemetry {
if (!telemetryInstance) {
telemetryInstance = new MarketTelemetry(userId);
} else if (userId !== undefined) {
telemetryInstance.setUserId(userId);
}
return telemetryInstance;
}