Логирование помогает отслеживать использование timeago.js в production, выявлять аномалии (слишком частые вызовы, невалидные даты) и собирать метрики производительности.
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogEntry {
level: LogLevel;
message: string;
timestamp: number;
data?: Record<string, unknown>;
}
class TimeagoLogger {
private level: LogLevel = 'warn';
setLevel(level: LogLevel): void {
this.level = level;
}
private shouldLog(level: LogLevel): boolean {
const ORDER: LogLevel[] = ['debug', 'info', 'warn', 'error'];
return ORDER.indexOf(level) >= ORDER.indexOf(this.level);
}
log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
if (!this.shouldLog(level)) return;
const entry: LogEntry = { level, message, timestamp: Date.now(), data };
console[level](`[timeago] ${message}`, data ?? '');
}
debug(msg: string, data?: Record<string, unknown>) { this.log('debug', msg, data); }
info(msg: string, data?: Record<string, unknown>) { this.log('info', msg, data); }
warn(msg: string, data?: Record<string, unknown>) { this.log('warn', msg, data); }
error(msg: string, data?: Record<string, unknown>) { this.log('error', msg, data); }
}
export const logger = new TimeagoLogger();
import { format as baseFormat } from 'timeago.js';
function monitoredFormat(date: unknown, locale = 'ru'): string {
const start = performance.now();
logger.debug('format called', { date, locale });
if (date == null) {
logger.warn('format called with null/undefined date', { locale });
return '';
}
const d = new Date(date as any);
if (isNaN(d.getTime())) {
logger.error('format called with invalid date', { date, locale });
return '';
}
const result = baseFormat(d, locale);
const elapsed = performance.now() - start;
if (elapsed > 5) {
logger.warn('format took too long', { elapsed, date, locale });
} else {
logger.debug('format completed', { result, elapsed });
}
return result;
}
interface CallStats {
total: number;
success: number;
errors: number;
lastAt: number;
}
class TimeagoStats {
private stats: Record<string, CallStats> = {
format: { total: 0, success: 0, errors: 0, lastAt: 0 },
render: { total: 0, success: 0, errors: 0, lastAt: 0 },
cancel: { total: 0, success: 0, errors: 0, lastAt: 0 },
};
record(fn: string, success: boolean): void {
const s = this.stats[fn];
if (!s) return;
s.total++;
s.lastAt = Date.now();
if (success) s.success++; else s.errors++;
}
report(): Record<string, CallStats> {
return { ...this.stats };
}
reset(): void {
Object.values(this.stats).forEach(s => {
s.total = s.success = s.errors = 0;
});
}
}
export const stats = new TimeagoStats();
const invalidDateLog: Array<{ date: unknown; locale: string; ts: number }> = [];
const MAX_LOG = 100;
function trackInvalidDate(date: unknown, locale: string): void {
if (invalidDateLog.length >= MAX_LOG) {
invalidDateLog.shift(); // Кольцевой буфер
}
invalidDateLog.push({ date, locale, ts: Date.now() });
logger.warn('Invalid date detected', { date, locale });
}
import * as Sentry from '@sentry/browser';
function sentryFormat(date: unknown, locale = 'ru'): string {
try {
if (date == null) throw new TypeError('date is null');
const d = new Date(date as any);
if (isNaN(d.getTime())) throw new TypeError(`Invalid date: ${date}`);
return format(d, locale);
} catch (err) {
Sentry.captureException(err, {
tags: { component: 'timeago' },
extra: { date, locale },
});
return '';
}
}
import { StatsD } from 'hot-shots'; // Или любой StatsD клиент
const statsd = new StatsD();
function metricsFormat(date: Date | string | number, locale = 'ru'): string {
const start = Date.now();
const result = format(date, locale);
const elapsed = Date.now() - start;
statsd.timing('timeago.format.duration', elapsed);
statsd.increment('timeago.format.calls', 1, { locale });
return result;
}
// Экспортировать в window для проверки через консоль
if (process.env.NODE_ENV !== 'production') {
(window as any).__timeago = {
stats: () => stats.report(),
invalidDates: () => [...invalidDateLog],
activeElements: () => document.querySelectorAll('[datetime]').length,
};
}
import { render as baseRender, cancel as baseCancel } from 'timeago.js';
const activeRenders = new Map<Element, string>(); // element → locale
function trackedRender(nodes: Element | Element[], locale = 'ru'): void {
const arr = Array.isArray(nodes) ? nodes : [nodes];
arr.forEach(el => {
if (activeRenders.has(el)) {
logger.warn('render called on already-rendered element', {
element: el.tagName,
existingLocale: activeRenders.get(el),
newLocale: locale,
});
}
activeRenders.set(el, locale);
});
logger.info(`render: ${arr.length} elements, locale="${locale}"`);
baseRender(nodes, locale);
}
function trackedCancel(nodes?: Element | Element[]): void {
const arr = nodes ? (Array.isArray(nodes) ? nodes : [nodes]) : [];
arr.forEach(el => activeRenders.delete(el));
logger.info(`cancel: ${arr.length} elements`);
baseCancel(nodes);
}
if (process.env.NODE_ENV === 'development') {
logger.setLevel('debug');
} else if (process.env.NODE_ENV === 'staging') {
logger.setLevel('info');
} else {
logger.setLevel('error'); // production — только критические ошибки
}
| Метрика | Пороговое значение | Действие при превышении |
|---|---|---|
| format duration | > 5 мс | Warn в лог |
| Количество невалидных дат/мин | > 10 | Alert в мониторинг |
| Активных render элементов | > 500 | Предупреждение о производительности |
| Количество errors за час | > 100 | Incident |