@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
190 lines • 8.35 kB
JavaScript
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