UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

190 lines 8.35 kB
import { mergeHistograms, normalizeHistogram } from '../rollup/rollup-store.js'; import { decodeCursor, encodeCursor } from './cursor.js'; const DEFAULT_LIMIT = 50; /** * Process-local store. Used as a test double and as a zero-dependency option * for single-instance/serverless setups. Newest-first ordering by createdAt * then id; cursor is an opaque keyset (createdAt-ms, id) position. */ export class InMemoryStorageProvider { entries = []; /** Materialized rollups keyed `${metric}|${bucketStart}`. */ rollups = new Map(); async store(entries) { this.entries.push(...entries); } async update(id, patch) { const index = this.entries.findIndex((entry) => entry.id === id); if (index === -1) return; const existing = this.entries[index]; if (existing === undefined) return; this.entries[index] = { ...existing, ...patch, id: existing.id }; } async find(id) { const entry = this.entries.find((candidate) => candidate.id === id); if (!entry) { return null; } return { ...entry, batch: await this.batch(entry.batchId) }; } async get(query) { // Build the ids membership set once (not per entry) when an ids filter is set. const idSet = query.ids !== undefined ? new Set(query.ids) : null; const filtered = [...this.entries] .filter((entry) => this.matches(entry, query, idSet)) .sort(this.newestFirst); const cursor = query.cursor ? decodeCursor(query.cursor) : null; const afterCursor = cursor ? filtered.filter((e) => e.createdAt.getTime() < cursor.createdAt || (e.createdAt.getTime() === cursor.createdAt && e.id < cursor.id)) : filtered; const limit = query.limit !== undefined && Number.isInteger(query.limit) && query.limit > 0 ? query.limit : DEFAULT_LIMIT; const slice = afterCursor.slice(0, limit); const hasMore = afterCursor.length > limit; const last = slice.at(-1); // omitContent: hand back shallow copies with content nulled so callers can // never accidentally depend on content during a content-less aggregate scan. const data = query.omitContent ? slice.map((entry) => ({ ...entry, content: null })) : slice; return { data, nextCursor: hasMore && last ? encodeCursor(last.createdAt.getTime(), last.id) : null, }; } async batch(batchId) { return this.entries .filter((entry) => entry.batchId === batchId) .sort((a, b) => a.sequence - b.sequence); } async tags(prefix) { const counts = new Map(); for (const entry of this.entries) { for (const tag of entry.tags) { if (!prefix || tag.startsWith(prefix)) { counts.set(tag, (counts.get(tag) ?? 0) + 1); } } } return [...counts.entries()].map(([tag, count]) => ({ tag, count })); } async prune(olderThan, keepLast) { const before = this.entries.length; let survivors = this.entries.filter((entry) => entry.createdAt >= olderThan); if (keepLast !== undefined && survivors.length < this.entries.length) { const pruned = [...this.entries] .filter((entry) => entry.createdAt < olderThan) .sort(this.newestFirst) .slice(0, keepLast); survivors = [...survivors, ...pruned]; } this.entries = survivors; return before - this.entries.length; } async pruneScoped(input) { const { before, type, excludeTypes, keepLast } = input; const excluded = excludeTypes !== undefined ? new Set(excludeTypes) : null; // A row is in scope when it is older than the cutoff AND matches the type // selector: a single `type`, OR (for the global bulk) any type NOT excluded. const inScope = (entry) => { if (entry.createdAt >= before) return false; if (type !== undefined) return entry.type === type; if (excluded !== null) return !excluded.has(entry.type); return true; }; const before_ = this.entries.length; const survivors = this.entries.filter((entry) => !inScope(entry)); if (keepLast !== undefined && survivors.length < this.entries.length) { // Resurrect the newest `keepLast` of the doomed rows, mirroring prune(). const reprieved = this.entries.filter(inScope).sort(this.newestFirst).slice(0, keepLast); this.entries = [...survivors, ...reprieved]; } else { this.entries = survivors; } return before_ - this.entries.length; } async clear() { this.entries = []; this.rollups.clear(); this.seenFamilies.clear(); } /** Last-seen wall time (ms) per error family, backing the shared new-exception * dedup. In a single process this is exactly the per-replica behaviour; the * real cross-pod win comes from the SQLite/Redis providers that share a store. */ seenFamilies = new Map(); async markFamilySeen(familyHash, nowMs, windowMs) { const last = this.seenFamilies.get(familyHash); const isNew = last === undefined || nowMs - last >= windowMs; this.seenFamilies.set(familyHash, nowMs); return isNew; } // ── RollupStore SPI ──────────────────────────────────────────────────────── async recordRollups(deltas) { for (const delta of deltas) { const key = `${delta.metric}|${delta.bucketStart}`; const existing = this.rollups.get(key); if (existing === undefined) { this.rollups.set(key, { metric: delta.metric, bucketStart: delta.bucketStart, count: delta.count, sum: delta.sum, max: delta.max, histogram: normalizeHistogram(delta.histogram), }); } else { existing.count += delta.count; existing.sum += delta.sum; existing.max = Math.max(existing.max, delta.max); existing.histogram = mergeHistograms(existing.histogram, delta.histogram); } } } async queryRollups(metrics, fromBucket, toBucket) { const wanted = new Set(metrics); const result = []; for (const bucket of this.rollups.values()) { if (wanted.has(bucket.metric) && bucket.bucketStart >= fromBucket && bucket.bucketStart <= toBucket) { result.push({ ...bucket, histogram: normalizeHistogram(bucket.histogram) }); } } return result; } matches(entry, query, idSet) { if (idSet !== null && !idSet.has(entry.id)) return false; if (query.type !== undefined && entry.type !== query.type) return false; if (query.tag !== undefined && !entry.tags.includes(query.tag)) return false; if (query.familyHash !== undefined && entry.familyHash !== query.familyHash) return false; if (query.batchId !== undefined && entry.batchId !== query.batchId) return false; if (query.traceId !== undefined && entry.traceId !== query.traceId) return false; if (query.search !== undefined && !JSON.stringify(entry.content).toLowerCase().includes(query.search.toLowerCase())) { return false; } if (query.before !== undefined && entry.createdAt >= query.before) return false; if (query.after !== undefined && entry.createdAt <= query.after) return false; return true; } newestFirst = (a, b) => { const delta = b.createdAt.getTime() - a.createdAt.getTime(); return delta !== 0 ? delta : b.id.localeCompare(a.id); }; } //# sourceMappingURL=in-memory-storage-provider.js.map