@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
288 lines • 14.2 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var TelescopePruner_1;
// packages/core/src/nest/telescope-pruner.service.ts
import { Inject, Injectable, Logger, } from '@nestjs/common';
import { TELESCOPE_CONFIG, TELESCOPE_STORAGE } from './telescope.options.js';
/** Ring-buffer cap for recent prune runs (newest-first). */
const MAX_PRUNE_RUNS = 100;
let TelescopePruner = TelescopePruner_1 = class TelescopePruner {
config;
storage;
logger = new Logger(TelescopePruner_1.name);
timer = null;
/**
* Latches once after the FIRST time we fall back from a missing
* `pruneScoped` to the global `prune`, so a third-party provider without
* per-type support logs the capability warning a single time, not every tick.
*/
warnedNoScopedPrune = false;
/** Recent prune cycles, newest-first, capped at {@link MAX_PRUNE_RUNS}. */
runs = [];
/** Start time (epoch ms) of the most recent SCHEDULED cycle, for nextRunAt. */
lastScheduledRunAtMs = null;
constructor(config, storage) {
this.config = config;
this.storage = storage;
}
onApplicationBootstrap() {
const prune = this.config.prune;
if (!this.config.enabled || !prune)
return;
this.timer = setInterval(() => {
// The whole cycle is fire-and-forget on an unref'd timer: a rejection here
// must never become an unhandled rejection or crash the host, so we always
// catch. Individual sub-steps already swallow their own failures so one bad
// type can't abort the rest of the cycle; this is the final backstop.
this.runCycle(prune, 'scheduled').catch((error) => {
this.logger.warn(`Telescope prune failed: ${asError(error).message}`);
});
}, prune.intervalMs);
this.timer.unref?.();
}
/**
* Run ONE prune cycle on demand (the dashboard's "Prune now" button → the
* controller's `retention/prune` route), recording it as a `manual` run.
* Returns the total number of entries deleted. Throws only if `prune` is
* unconfigured — the caller (controller) gates that and the mutation guard.
*/
async pruneNow() {
const prune = this.config.prune;
if (!prune)
return 0;
return this.runCycle(prune, 'manual');
}
/** Recent prune runs (newest-first), copied so callers can't mutate the ring. */
getRuns() {
return [...this.runs];
}
/**
* Predicted next SCHEDULED prune time (epoch ms), or null when no `prune`
* window is configured. Derived from the last scheduled run's start + the
* interval, falling back to now + interval before the first cycle has run.
*/
getNextRunAtMs() {
const prune = this.config.prune;
if (!prune)
return null;
return (this.lastScheduledRunAtMs ?? Date.now()) + prune.intervalMs;
}
onApplicationShutdown() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* One prune tick. The retention model is:
* - Each type that needs INDIVIDUAL handling — one with a `perType` override
* OR an archived type (which must be exported before its own delete) — is
* pruned in its OWN scope, at its own cutoff (its `perType` value, else the
* global `after`), with archiving (when configured) first.
* - Every OTHER type is pruned in a single bulk delete at the global cutoff,
* with the individually-handled types carved out.
*
* Archived types are ALWAYS carved out of the bulk delete even with no `perType`
* override, so a failed sink can spare them (the bulk delete would otherwise
* wipe entries the sink never saw). With no overrides and no archive (the common
* case) the individual set is empty and this collapses to exactly one global
* `prune(cutoff, keepLast)` — identical to the historical behaviour.
*/
async runCycle(prune, trigger) {
const startedAtMs = Date.now();
const archivedTypes = this.config.archive?.types ?? new Set();
// Types needing their own scope: per-type overrides ∪ archived types.
const individualTypes = new Set([...Object.keys(prune.perTypeMs), ...archivedTypes]);
const deletedByType = {};
let deletedTotal = 0;
let archivedTotal = 0;
let error;
// 1) Bulk prune for every type NOT handled individually, at the global cutoff.
// With an empty individual set this is a plain global prune, so legacy
// providers that only have `prune` behave exactly as before. The bulk
// delete spans many types and returns a single aggregate count, so it
// contributes to `deletedTotal` but is not attributed to any type key.
const globalCutoff = new Date(startedAtMs - prune.afterMs);
const bulk = await this.pruneArchivedThenDelete(globalCutoff, prune.keepLast, {
before: globalCutoff,
...(individualTypes.size > 0 ? { excludeTypes: [...individualTypes] } : {}),
...(prune.keepLast !== undefined ? { keepLast: prune.keepLast } : {}),
});
deletedTotal += bulk.deleted;
archivedTotal += bulk.archived;
if (bulk.error !== undefined && error === undefined)
error = bulk.error;
// 2) One scoped prune per individually-handled type, at its own cutoff (its
// perType override when present, else the global cutoff), archiving first.
// These scopes ARE type-attributable, so their counts populate
// `deletedByType` with real per-type numbers.
for (const type of individualTypes) {
const afterMs = prune.perTypeMs[type] ?? prune.afterMs;
const cutoff = new Date(startedAtMs - afterMs);
const step = await this.pruneArchivedThenDelete(cutoff, prune.keepLast, {
before: cutoff,
type,
...(prune.keepLast !== undefined ? { keepLast: prune.keepLast } : {}),
}, type);
if (step.deleted > 0)
deletedByType[type] = (deletedByType[type] ?? 0) + step.deleted;
deletedTotal += step.deleted;
archivedTotal += step.archived;
if (step.error !== undefined && error === undefined)
error = step.error;
}
if (trigger === 'scheduled')
this.lastScheduledRunAtMs = startedAtMs;
this.recordRun({
at: new Date(startedAtMs).toISOString(),
trigger,
durationMs: Date.now() - startedAtMs,
deletedTotal,
deletedByType,
...(archivedTotal > 0 ? { archivedTotal } : {}),
...(error !== undefined ? { error } : {}),
});
return deletedTotal;
}
/**
* Append a run to the newest-first ring buffer, evicting the oldest past the
* cap. Recording must NEVER throw into the prune path (a bad ISO/serialization
* would otherwise turn observability into an outage), so it is fully guarded.
*/
recordRun(run) {
try {
this.runs.unshift(run);
while (this.runs.length > MAX_PRUNE_RUNS)
this.runs.pop();
}
catch (error) {
this.logger.warn(`Telescope failed to record prune run: ${asError(error).message}`);
}
}
/**
* Archives (if configured) the entries this `scope` is about to delete, then
* deletes them. When the scope targets a single archived `type` whose sink
* fails, the delete is SKIPPED (entries survive to retry next cycle) but the
* caller's other scopes are unaffected. Errors never propagate out of here.
*
* `fallbackOlderThan`/`fallbackKeepLast` are used only by the legacy global
* fallback path when the provider lacks `pruneScoped`.
*/
async pruneArchivedThenDelete(fallbackOlderThan, fallbackKeepLast, scope, archivableType) {
try {
// Archive must complete before the matching delete. If it throws for a
// single-type scope, bail WITHOUT deleting so the data survives.
const { proceed, archived } = await this.archiveScope(scope, archivableType);
if (!proceed)
return { deleted: 0, archived };
const deleted = await this.deleteScope(scope, fallbackOlderThan, fallbackKeepLast);
return { deleted, archived };
}
catch (error) {
const message = asError(error).message;
this.logger.warn(`Telescope prune step failed: ${message}`);
return { deleted: 0, archived: 0, error: message };
}
}
/**
* Exports the doomed entries for an archived single-type scope to the sink in
* bounded batches. `proceed` is `true` when it is safe to delete (nothing to
* archive, archiving succeeded, or this type/scope is not archived) and
* `false` when the sink failed (skip the delete this cycle); `archived` is the
* number of entries actually handed to the sink.
*/
async archiveScope(scope, archivableType) {
const archive = this.config.archive;
// Only single-type scopes that are in the archive set are exported. The
// global bulk scope (excludeTypes) is never archived: archived types always
// get their own per-type scope, so they are never part of the bulk delete.
if (archive === undefined ||
archivableType === undefined ||
!archive.types.has(archivableType)) {
return { proceed: true, archived: 0 };
}
let archived = 0;
try {
let batchesDone = 0;
let cursor;
while (batchesDone < archive.maxBatchesPerCycle) {
const page = await this.storage.get({
type: archivableType,
before: scope.before,
limit: archive.batchSize,
...(cursor !== undefined ? { cursor } : {}),
});
if (page.data.length === 0)
break;
await archive.sink(page.data);
archived += page.data.length;
batchesDone += 1;
if (page.nextCursor === null)
break;
cursor = page.nextCursor;
if (batchesDone === archive.maxBatchesPerCycle && page.nextCursor !== null) {
// Backlog exceeds this cycle's cap. Stop here; the remainder is
// archived next tick. We deliberately do NOT delete this type now,
// because the unarchived remainder is exactly what we must keep — the
// delete cutoff would otherwise wipe entries the sink never saw.
this.logger.warn(`Telescope archive for type "${archivableType}" hit the per-cycle batch cap ` +
`(${archive.maxBatchesPerCycle}); remaining entries deferred to next cycle.`);
return { proceed: false, archived };
}
}
return { proceed: true, archived };
}
catch (error) {
// Rate-limited to once per cycle (this method runs once per archived type
// per cycle). Skip the delete so the doomed entries survive for a retry.
this.logger.warn(`Telescope archive sink failed for type "${archivableType}"; ` +
`skipping its prune this cycle: ${asError(error).message}`);
return { proceed: false, archived };
}
}
/**
* Deletes the scope via the provider's `pruneScoped` when available, else
* falls back ONCE (with a logged warning) to the legacy global `prune` — which
* uses the global cutoff for ALL types, the best a provider without per-type
* support can do. The fallback runs only for the global scope to avoid
* deleting more than intended on a per-type scope.
*/
async deleteScope(scope, fallbackOlderThan, fallbackKeepLast) {
if (this.storage.pruneScoped !== undefined) {
return this.storage.pruneScoped(scope);
}
if (!this.warnedNoScopedPrune) {
this.warnedNoScopedPrune = true;
this.logger.warn('Storage provider does not implement pruneScoped(); per-type retention ' +
'is unavailable. Falling back to the global prune cutoff for all types.');
}
// Run the legacy global prune ONLY for the global (non-type) scope, so the
// fallback prunes the whole store once at the global cutoff rather than
// re-running per overridden type (which the global prune can't scope).
if (scope.type === undefined) {
return this.storage.prune(fallbackOlderThan, fallbackKeepLast);
}
return 0;
}
};
TelescopePruner = TelescopePruner_1 = __decorate([
Injectable(),
__param(0, Inject(TELESCOPE_CONFIG)),
__param(1, Inject(TELESCOPE_STORAGE)),
__metadata("design:paramtypes", [Object, Object])
], TelescopePruner);
export { TelescopePruner };
function asError(error) {
return error instanceof Error ? error : new Error(String(error));
}
//# sourceMappingURL=telescope-pruner.service.js.map