analogger 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3566,6 +3566,385 @@ class ____AnaLogger
3566
3566
  return false;
3567
3567
  }
3568
3568
 
3569
+ // ─── Snapshot storage ─────────────────────────────────────────────────────
3570
+ // Browser → localStorage
3571
+ // Node.js → os.tmpdir()/analogger-snapshots/<key>.json (persists across runs)
3572
+
3573
+ /**
3574
+ * Resolve the file path used to persist a snapshot in Node.js.
3575
+ * The directory is created on first use.
3576
+ * @param {string} key – storage key, e.g. "analogger_snapshot_default_SNAP01"
3577
+ * @returns {string} – absolute file path
3578
+ */
3579
+ #_snapshotFilePath(key) {
3580
+ /** to-esm-browser: remove **/
3581
+ const dir = path.join(os.tmpdir(), "analogger-snapshots");
3582
+ if (!fs.existsSync(dir)) {
3583
+ fs.mkdirSync(dir, { recursive: true });
3584
+ }
3585
+ // Sanitise the key so it is always a safe filename
3586
+ const safeName = key.replace(/[^a-zA-Z0-9_\-]/g, "_") + ".json";
3587
+ return path.join(dir, safeName);
3588
+ /** to-esm-browser: end-remove **/
3589
+ }
3590
+
3591
+ #_snapshotGet(key) {
3592
+ // Browser path
3593
+ try {
3594
+ if (this.isBrowser() && typeof localStorage !== "undefined") {
3595
+ return localStorage.getItem(key);
3596
+ }
3597
+ } catch (e) {}
3598
+
3599
+ // Node.js path – read from temp file
3600
+ /** to-esm-browser: remove **/
3601
+ try {
3602
+ const filePath = this.#_snapshotFilePath(key);
3603
+ if (fs.existsSync(filePath)) {
3604
+ return fs.readFileSync(filePath, "utf8");
3605
+ }
3606
+ } catch (e) {
3607
+ console.error("[AnaLogger] snapshot read error:", e.message);
3608
+ }
3609
+ /** to-esm-browser: end-remove **/
3610
+
3611
+ return null;
3612
+ }
3613
+
3614
+ #_snapshotSet(key, value) {
3615
+ // Browser path
3616
+ try {
3617
+ if (this.isBrowser() && typeof localStorage !== "undefined") {
3618
+ localStorage.setItem(key, value);
3619
+ return;
3620
+ }
3621
+ } catch (e) {}
3622
+
3623
+ // Node.js path – write to temp file
3624
+ /** to-esm-browser: remove **/
3625
+ try {
3626
+ const filePath = this.#_snapshotFilePath(key);
3627
+ fs.writeFileSync(filePath, value, "utf8");
3628
+ console.log(`[AnaLogger] Snapshot persisted to: ${filePath}`);
3629
+ } catch (e) {
3630
+ console.error("[AnaLogger] snapshot write error:", e.message);
3631
+ }
3632
+ /** to-esm-browser: end-remove **/
3633
+ }
3634
+
3635
+ #_snapshotKey(snapshotID) {
3636
+ return `analogger_snapshot_${this.instanceName || "default"}_${snapshotID}`;
3637
+ }
3638
+
3639
+ /**
3640
+ * Harvest lid entries from the current log history.
3641
+ * Only entries logged after the last startSnapshotProcess() call are included
3642
+ * when a snapshot window is active.
3643
+ * @param {{ messages?: boolean, context?: boolean }} opts
3644
+ * @returns {object[]}
3645
+ */
3646
+ #_harvestSnapshot(opts = {}) {
3647
+ const { messages = true, context: includeContext = true } = opts;
3648
+ const fullHistory = this.getRawLogHistory ? this.getRawLogHistory() : (this.logHistory || []);
3649
+
3650
+ // If a snapshot window is active, slice from the marker index
3651
+ const history = this._snapshotWindowStart !== undefined
3652
+ ? fullHistory.slice(this._snapshotWindowStart)
3653
+ : fullHistory;
3654
+
3655
+ /** Deduplicate by lid - keep the last occurrence */
3656
+ const seen = new Map();
3657
+ for (const entry of history) {
3658
+ const ctx = entry.context || {};
3659
+ const lid = ctx.lid;
3660
+ if (!lid) continue;
3661
+ const record = { lid };
3662
+ if (messages) record.message = entry.message ?? "";
3663
+ if (includeContext) record.context = { ...ctx };
3664
+ seen.set(lid, record);
3665
+ }
3666
+ return Array.from(seen.values());
3667
+ }
3668
+
3669
+ /**
3670
+ * Mark the start of a snapshot window.
3671
+ * When called, any subsequent takeSnapshot() will only capture lids logged
3672
+ * from this point forward, ignoring everything already in the history.
3673
+ * Calling it again moves the window start to the current position.
3674
+ *
3675
+ * @example
3676
+ * anaLogger.keepLogHistory();
3677
+ * anaLogger.log({ lid: "WEB0001" }, "boot"); // excluded
3678
+ * anaLogger.startSnapshotProcess();
3679
+ * anaLogger.log({ lid: "WEB0002" }, "step 1"); // included
3680
+ * anaLogger.takeSnapshot("SNAP01"); // captures only WEB0002
3681
+ */
3682
+ startSnapshotProcess() {
3683
+ const history = this.getRawLogHistory ? this.getRawLogHistory() : (this.logHistory || []);
3684
+ this._snapshotWindowStart = history.length;
3685
+ console.log(
3686
+ `[AnaLogger] Snapshot window started at history index ${this._snapshotWindowStart}.`
3687
+ );
3688
+ }
3689
+
3690
+ /**
3691
+ * Capture all lids logged since the last startSnapshotProcess() (or since the
3692
+ * beginning of the session if startSnapshotProcess was never called) into a
3693
+ * named snapshot stored in localStorage / temp file.
3694
+ *
3695
+ * @param {string} snapshotID - unique label, e.g. "SNAP01"
3696
+ * @param {{ messages?: boolean, context?: boolean }} [opts]
3697
+ * messages: true - also store the log message per lid (default: true)
3698
+ * context: true - also store the full context object (default: true)
3699
+ * @returns {object[]} The array of snapshot entries that were saved
3700
+ *
3701
+ * @example
3702
+ * anaLogger.keepLogHistory();
3703
+ * anaLogger.log({ lid: "WEB0001" }, "step 1");
3704
+ * const entries = anaLogger.takeSnapshot("SNAP01");
3705
+ * anaLogger.log({ lid: "WEB0002" }, "step 2");
3706
+ * anaLogger.takeSnapshot("SNAP02", { messages: false, context: false });
3707
+ */
3708
+ takeSnapshot(snapshotID, opts = {}) {
3709
+ try {
3710
+ if (!snapshotID || typeof snapshotID !== "string") {
3711
+ console.warn("[AnaLogger] takeSnapshot: snapshotID must be a non-empty string.");
3712
+ return [];
3713
+ }
3714
+
3715
+ const { messages = true, context: includeContext = true } = opts;
3716
+ const entries = this.#_harvestSnapshot({ messages, context: includeContext });
3717
+
3718
+ const payload = {
3719
+ snapshotID,
3720
+ timestamp : Date.now(),
3721
+ options : { messages, context: includeContext },
3722
+ entries,
3723
+ };
3724
+
3725
+ this.#_snapshotSet(this.#_snapshotKey(snapshotID), JSON.stringify(payload));
3726
+
3727
+ console.log(
3728
+ `[AnaLogger] Snapshot "${snapshotID}" saved - ${entries.length} lid(s) captured.`
3729
+ );
3730
+
3731
+ return entries;
3732
+ } catch (e) {
3733
+ console.error("[AnaLogger] takeSnapshot error:", e.message);
3734
+ return [];
3735
+ }
3736
+ }
3737
+
3738
+ /**
3739
+ * Compare two previously saved snapshots and print a side-by-side diff table
3740
+ * showing which lids are shared, present only in snap1, or only in snap2.
3741
+ *
3742
+ * @param {string} snapshotID1
3743
+ * @param {string} snapshotID2
3744
+ * @param {[{ messages?: boolean }, { messages?: boolean }]} [displayOpts]
3745
+ * Optional per-snapshot display options.
3746
+ * displayOpts[0].messages = true - add a message column for snapshotID1
3747
+ * displayOpts[1].messages = true - add a message column for snapshotID2
3748
+ * @returns {{ onlyInSnap1: string[], onlyInSnap2: string[], inBoth: string[] } | null}
3749
+ *
3750
+ * @example
3751
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02");
3752
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: true }]);
3753
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: false }]);
3754
+ */
3755
+ compareSnapshots(snapshotID1, snapshotID2, displayOpts = []) {
3756
+ try {
3757
+ const raw1 = this.#_snapshotGet(this.#_snapshotKey(snapshotID1));
3758
+ const raw2 = this.#_snapshotGet(this.#_snapshotKey(snapshotID2));
3759
+
3760
+ if (!raw1) console.warn(`[AnaLogger] compareSnapshots: snapshot "${snapshotID1}" not found.`);
3761
+ if (!raw2) console.warn(`[AnaLogger] compareSnapshots: snapshot "${snapshotID2}" not found.`);
3762
+ if (!raw1 || !raw2) return null;
3763
+
3764
+ const snap1 = JSON.parse(raw1);
3765
+ const snap2 = JSON.parse(raw2);
3766
+
3767
+ // Resolve per-snapshot message display flags
3768
+ const showMsg1 = !!(displayOpts[0] && displayOpts[0].messages);
3769
+ const showMsg2 = !!(displayOpts[1] && displayOpts[1].messages);
3770
+
3771
+ const map1 = new Map(snap1.entries.map((e) => [e.lid, e]));
3772
+ const map2 = new Map(snap2.entries.map((e) => [e.lid, e]));
3773
+ const allLids = new Set([...map1.keys(), ...map2.keys()]);
3774
+
3775
+ const inBoth = [];
3776
+ const onlyIn1 = [];
3777
+ const onlyIn2 = [];
3778
+
3779
+ for (const lid of allLids) {
3780
+ const in1 = map1.has(lid);
3781
+ const in2 = map2.has(lid);
3782
+ if (in1 && in2) inBoth.push(lid);
3783
+ else if (in1) onlyIn1.push(lid);
3784
+ else onlyIn2.push(lid);
3785
+ }
3786
+
3787
+ // Build display rows: shared lids paired, then staggered exclusives
3788
+ const rows = [];
3789
+ for (const lid of inBoth) {
3790
+ rows.push({
3791
+ left: lid, right: lid, status: "both",
3792
+ msgLeft : map1.get(lid)?.message ?? "",
3793
+ msgRight: map2.get(lid)?.message ?? "",
3794
+ });
3795
+ }
3796
+ const maxOnly = Math.max(onlyIn1.length, onlyIn2.length);
3797
+ for (let i = 0; i < maxOnly; i++) {
3798
+ const l = onlyIn1[i] ?? "";
3799
+ const r = onlyIn2[i] ?? "";
3800
+ rows.push({
3801
+ left: l, right: r,
3802
+ status: l && r ? "diff" : (l ? "left" : "right"),
3803
+ msgLeft : l ? (map1.get(l)?.message ?? "") : "",
3804
+ msgRight: r ? (map2.get(r)?.message ?? "") : "",
3805
+ });
3806
+ }
3807
+
3808
+ this.#_renderSnapshotTable(
3809
+ snapshotID1, snapshotID2, rows, snap1, snap2,
3810
+ { showMsg1, showMsg2 }
3811
+ );
3812
+
3813
+ return { onlyInSnap1: onlyIn1, onlyInSnap2: onlyIn2, inBoth };
3814
+ } catch (e) {
3815
+ console.error("[AnaLogger] compareSnapshots error:", e.message);
3816
+ return null;
3817
+ }
3818
+ }
3819
+
3820
+ // ─── Snapshot table renderers ─────────────────────────────────────────────
3821
+
3822
+ #_renderSnapshotTable(id1, id2, rows, snap1, snap2, { showMsg1 = false, showMsg2 = false } = {}) {
3823
+ const ts = (snap) =>
3824
+ snap.timestamp ? new Date(snap.timestamp).toLocaleTimeString() : "-";
3825
+
3826
+ // ── Header ───────────────────────────────────────────────────────────
3827
+ console.log(`\nSnapshot comparison: "${id1}" vs "${id2}"`);
3828
+ console.log(` ${id1} captured at ${ts(snap1)} - ${snap1.entries.length} lid(s)`);
3829
+ console.log(` ${id2} captured at ${ts(snap2)} - ${snap2.entries.length} lid(s)\n`);
3830
+
3831
+ // ── console.table (auto-aligned in Node and browser devtools) ────────
3832
+ // Column keys are the snapshot IDs so headers are self-documenting.
3833
+ // Message columns are appended only when requested.
3834
+ const msgKey1 = `${id1} message`;
3835
+ const msgKey2 = `${id2} message`;
3836
+
3837
+ const tableData = rows.length
3838
+ ? rows.map((r) => {
3839
+ const row = { [id1]: r.left, [id2]: r.right };
3840
+ if (showMsg1) row[msgKey1] = r.msgLeft;
3841
+ if (showMsg2) row[msgKey2] = r.msgRight;
3842
+ return row;
3843
+ })
3844
+ : [(() => {
3845
+ const row = { [id1]: "(empty)", [id2]: "(empty)" };
3846
+ if (showMsg1) row[msgKey1] = "";
3847
+ if (showMsg2) row[msgKey2] = "";
3848
+ return row;
3849
+ })()];
3850
+
3851
+ console.table(tableData);
3852
+
3853
+ // ── Browser HTML widget (progressive enhancement) ────────────────────
3854
+ if (typeof document !== "undefined") {
3855
+ this.#_renderSnapshotHtml(id1, id2, rows, snap1, snap2, ts, { showMsg1, showMsg2 });
3856
+ }
3857
+ }
3858
+
3859
+ #_renderSnapshotHtml(id1, id2, rows, snap1, snap2, ts, { showMsg1 = false, showMsg2 = false } = {}) {
3860
+ try {
3861
+ const C = {
3862
+ both: "#1e293b", bothBg: "#f8fafc",
3863
+ left: "#7f1d1d", leftBg: "#fef2f2",
3864
+ right: "#14532d", rightBg: "#f0fdf4",
3865
+ msg: "#475569", msgBg: "#f8fafc",
3866
+ header: "#1e40af", border: "#e2e8f0",
3867
+ };
3868
+
3869
+ const sharedCount = snap1.entries.filter(e => snap2.entries.some(e2 => e2.lid === e.lid)).length;
3870
+ const only1Count = snap1.entries.length - sharedCount;
3871
+ const only2Count = snap2.entries.length - sharedCount;
3872
+
3873
+ // Total visible columns: lid1, [msg1], sym, lid2, [msg2]
3874
+ const totalCols = 3 + (showMsg1 ? 1 : 0) + (showMsg2 ? 1 : 0);
3875
+ const td = (extra = "") =>
3876
+ `padding:4px 10px;font-family:monospace;font-size:12px;border-bottom:1px solid ${C.border};${extra}`;
3877
+ const tdMsg = (extra = "") =>
3878
+ `padding:4px 10px;font-size:11px;color:${C.msg};border-bottom:1px solid ${C.border};max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;${extra}`;
3879
+ const thStyle = `padding:5px 10px;text-align:left;background:#f1f5f9;font-size:11px;color:#475569;border-bottom:2px solid ${C.border}`;
3880
+
3881
+ const mkRow = (row) => {
3882
+ const lBg = row.status === "left" ? C.leftBg : C.bothBg;
3883
+ const rBg = row.status === "right" ? C.rightBg : C.bothBg;
3884
+ const lCl = row.status === "left" ? C.left : row.status === "right" ? "#94a3b8" : C.both;
3885
+ const rCl = row.status === "right" ? C.right : row.status === "left" ? "#94a3b8" : C.both;
3886
+ const sym = row.status === "both" ? "=" : row.status === "left" ? "◀" : row.status === "right" ? "▶" : "≠";
3887
+
3888
+ return `<tr>
3889
+ <td style="${td(`background:${lBg};color:${lCl}`)}">${row.left || ""}</td>
3890
+ ${showMsg1 ? `<td style="${tdMsg(`background:${lBg}`)}" title="${(row.msgLeft || "").replace(/"/g, "&quot;")}">${row.msgLeft || ""}</td>` : ""}
3891
+ <td style="width:20px;text-align:center;border-bottom:1px solid ${C.border};color:#94a3b8;font-size:10px">${sym}</td>
3892
+ <td style="${td(`background:${rBg};color:${rCl}`)}">${row.right || ""}</td>
3893
+ ${showMsg2 ? `<td style="${tdMsg(`background:${rBg}`)}" title="${(row.msgRight || "").replace(/"/g, "&quot;")}">${row.msgRight || ""}</td>` : ""}
3894
+ </tr>`;
3895
+ };
3896
+
3897
+ const html = `
3898
+ <div id="analogger-snapshot-cmp" style="
3899
+ font-family:system-ui,sans-serif;font-size:13px;
3900
+ border:1px solid ${C.border};border-radius:8px;overflow:hidden;
3901
+ max-width:${showMsg1 || showMsg2 ? "820px" : "520px"};margin:12px 0;box-shadow:0 2px 8px rgba(0,0,0,.08)">
3902
+ <div style="background:${C.header};color:#fff;padding:8px 14px;font-weight:600">
3903
+ Snapshot diff &mdash;
3904
+ <code style="font-size:12px">${id1}</code>
3905
+ <span style="opacity:.7"> vs </span>
3906
+ <code style="font-size:12px">${id2}</code>
3907
+ </div>
3908
+ <div style="display:flex;background:#e2e8f0;font-size:11px;color:#64748b;padding:3px 0">
3909
+ <span style="flex:1;padding-left:10px">${id1} &middot; ${ts(snap1)} &middot; ${snap1.entries.length} lids</span>
3910
+ <span style="width:20px"></span>
3911
+ <span style="flex:1;padding-left:10px">${id2} &middot; ${ts(snap2)} &middot; ${snap2.entries.length} lids</span>
3912
+ </div>
3913
+ <table style="width:100%;border-collapse:collapse">
3914
+ <thead>
3915
+ <tr>
3916
+ <th style="${thStyle}">${id1}</th>
3917
+ ${showMsg1 ? `<th style="${thStyle};color:#94a3b8;font-style:italic">message</th>` : ""}
3918
+ <th style="width:20px;background:#f1f5f9;border-bottom:2px solid ${C.border}"></th>
3919
+ <th style="${thStyle}">${id2}</th>
3920
+ ${showMsg2 ? `<th style="${thStyle};color:#94a3b8;font-style:italic">message</th>` : ""}
3921
+ </tr>
3922
+ </thead>
3923
+ <tbody>
3924
+ ${rows.length
3925
+ ? rows.map(mkRow).join("")
3926
+ : `<tr><td colspan="${totalCols}" style="padding:10px;color:#94a3b8;text-align:center">(no lids in either snapshot)</td></tr>`}
3927
+ </tbody>
3928
+ </table>
3929
+ <div style="background:#f8fafc;padding:5px 10px;font-size:11px;color:#64748b;display:flex;gap:16px">
3930
+ <span>&#x2714; ${sharedCount} shared</span>
3931
+ <span style="color:${C.left}">&#x25C4; ${only1Count} only in ${id1}</span>
3932
+ <span style="color:${C.right}">&#x25BA; ${only2Count} only in ${id2}</span>
3933
+ </div>
3934
+ </div>`;
3935
+
3936
+ const $old = document.getElementById("analogger-snapshot-cmp");
3937
+ if ($old) $old.remove();
3938
+
3939
+ const $mount = document.querySelector("#analogger") || document.body;
3940
+ const $wrapper = document.createElement("div");
3941
+ $wrapper.innerHTML = html;
3942
+ $mount.appendChild($wrapper.firstElementChild);
3943
+ } catch (e) {
3944
+ // Non-fatal - plain-text already printed
3945
+ }
3946
+ }
3947
+
3569
3948
  /**
3570
3949
  * Set default targets, contexts and log levels
3571
3950
  * @returns {boolean}