// visitor-backfill-from-logs — one-shot importer. // // Parses [property-recommended] and [property-card-click] log lines from // the platform log and writes them into the visitor graph: // // [property-recommended] sessionKey= listingSlug= ts= // -> MERGE (Session {sessionKey}) // CREATE (Recommendation {sessionKey, listingSlug, recommendedAt}) // MERGE (Recommendation)-[:FOR_SESSION]->(Session) // // [property-card-click] sessionKey= listingSlug= ts= // -> MATCH/MERGE Session, CREATE Click {label:'card', listingSlug} // MERGE Session-[:HAS_EVENT]->Click // // Recovery path: used when a live POST /v/event write was lost (process // restart, dropped connection) or for ad-hoc forensics. The graph is // the canonical surface — these log lines are the join key, not the // aggregation path. // // Idempotency: re-running over the same log window does not duplicate // Recommendation nodes (MERGE on the exact sessionKey + slug + ts tuple). // Click nodes are immutable event records by design; if you re-run on a // window already processed you get duplicates. import { readFileSync } from "node:fs"; import { getSession } from "../lib/neo4j.js"; export interface BackfillParams { accountId: string; logPath: string; sinceIso?: string; untilIso?: string; batchSize?: number; } export interface BackfillResult { windowStart: string | null; windowEnd: string | null; recommendedParsed: number; recommendedWritten: number; recommendedSkippedDuplicate: number; clickedParsed: number; clickedWritten: number; unparseable: number; } interface RecLine { kind: "rec"; sessionKey: string; listingSlug: string; ts: string } interface ClickLine { kind: "click"; sessionKey: string; listingSlug: string; ts: string } type Line = RecLine | ClickLine const REC_RE = /\[property-recommended\]\s+sessionKey=(\S+)\s+listingSlug=(\S+)\s+ts=(\S+)/; const CLICK_RE = /\[property-card-click\]\s+sessionKey=(\S+)\s+listingSlug=(\S+)\s+ts=(\S+)/; const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; function parseLine(line: string): Line | null { let m = REC_RE.exec(line); if (m) return { kind: "rec", sessionKey: m[1], listingSlug: m[2], ts: m[3] }; m = CLICK_RE.exec(line); if (m) return { kind: "click", sessionKey: m[1], listingSlug: m[2], ts: m[3] }; return null; } function inWindow(iso: string, sinceMs: number | null, untilMs: number | null): boolean { if (!ISO_RE.test(iso)) return false; const t = Date.parse(iso); if (Number.isNaN(t)) return false; if (sinceMs !== null && t < sinceMs) return false; if (untilMs !== null && t >= untilMs) return false; return true; } export async function visitorBackfillFromLogs(p: BackfillParams): Promise { const sinceMs = p.sinceIso ? Date.parse(p.sinceIso) : null; const untilMs = p.untilIso ? Date.parse(p.untilIso) : null; const batchSize = Math.max(10, Math.min(p.batchSize ?? 200, 2000)); let body: string; try { body = readFileSync(p.logPath, "utf-8"); } catch (err) { throw new Error(`[visitor-backfill] log read failed path=${p.logPath} err=${(err as Error).message}`); } const rec: RecLine[] = []; const click: ClickLine[] = []; let unparseable = 0; let windowStart: string | null = null; let windowEnd: string | null = null; for (const raw of body.split("\n")) { if (!raw || (!raw.includes("[property-recommended]") && !raw.includes("[property-card-click]"))) continue; const parsed = parseLine(raw); if (!parsed) { unparseable += 1; continue; } if (!inWindow(parsed.ts, sinceMs, untilMs)) continue; if (parsed.kind === "rec") rec.push(parsed); else click.push(parsed); if (windowStart === null || parsed.ts < windowStart) windowStart = parsed.ts; if (windowEnd === null || parsed.ts > windowEnd) windowEnd = parsed.ts; } const session = getSession(); let recommendedWritten = 0; let recommendedSkippedDuplicate = 0; let clickedWritten = 0; console.error(`[visitor-backfill] start accountId=${p.accountId} window=${windowStart}..${windowEnd} rec=${rec.length} click=${click.length}`); try { for (let i = 0; i < rec.length; i += batchSize) { const slice = rec.slice(i, i + batchSize); const result = await session.run( `UNWIND $rows AS row MERGE (s:Session {accountId: $accountId, sessionId: row.sessionKey}) ON CREATE SET s.startedAt = datetime(row.ts), s.lastSeenAt = datetime(row.ts), s.backfilled = true ON MATCH SET s.lastSeenAt = CASE WHEN s.lastSeenAt > datetime(row.ts) THEN s.lastSeenAt ELSE datetime(row.ts) END MERGE (r:Recommendation {accountId: $accountId, sessionKey: row.sessionKey, listingSlug: row.listingSlug, recommendedAt: datetime(row.ts)}) ON CREATE SET r.createdAt = datetime(), r.created = true ON MATCH SET r.created = false MERGE (r)-[:FOR_SESSION]->(s) RETURN sum(CASE WHEN r.created THEN 1 ELSE 0 END) AS written, sum(CASE WHEN r.created THEN 0 ELSE 1 END) AS skipped`, { accountId: p.accountId, rows: slice }, ); const row = result.records[0]; recommendedWritten += Number(row?.get("written") ?? 0); recommendedSkippedDuplicate += Number(row?.get("skipped") ?? 0); } for (let i = 0; i < click.length; i += batchSize) { const slice = click.slice(i, i + batchSize); const result = await session.run( `UNWIND $rows AS row MERGE (s:Session {accountId: $accountId, sessionId: row.sessionKey}) ON CREATE SET s.startedAt = datetime(row.ts), s.lastSeenAt = datetime(row.ts), s.backfilled = true ON MATCH SET s.lastSeenAt = CASE WHEN s.lastSeenAt > datetime(row.ts) THEN s.lastSeenAt ELSE datetime(row.ts) END CREATE (cl:Click {accountId: $accountId, label: 'card', href: '', listingSlug: row.listingSlug, pageViewId: '', occurredAt: datetime(row.ts), backfilled: true}) MERGE (s)-[:HAS_EVENT]->(cl) RETURN count(cl) AS written`, { accountId: p.accountId, rows: slice }, ); const row = result.records[0]; clickedWritten += Number(row?.get("written") ?? 0); } console.error( `[visitor-backfill] done recParsed=${rec.length} recWritten=${recommendedWritten} clickParsed=${click.length} clickWritten=${clickedWritten} unparseable=${unparseable}`, ); return { windowStart, windowEnd, recommendedParsed: rec.length, recommendedWritten, recommendedSkippedDuplicate, clickedParsed: click.length, clickedWritten, unparseable, }; } finally { await session.close(); } }