@aimee-blue/ab-service-kit
Version:
Aimee Blue Service Template
359 lines (316 loc) • 8.04 kB
text/typescript
import { Observable, Subject } from 'rxjs';
import { ignoreElements, tap, filter, scan, map } from 'rxjs/operators';
import { EOL } from 'os';
import { TagNotification, executeOnNotifications } from '../notifications';
import {
Logger,
defaultLogger,
logEvents,
BasicLogger,
defaultBasicLogger,
LogOn,
} from '../logging';
type Timestamp = [number, number];
interface IDetails {
[key: string]: string | number | boolean;
}
interface ITagState {
hitTime?: Timestamp;
}
function timestampToTime(timestamp: Timestamp, since?: Timestamp) {
if (since) {
const time = process.hrtime(since);
return time[0] * 1e3 + time[1] / 1e6;
} else {
return timestamp[0] * 1e3 + timestamp[1] / 1e6;
}
}
const allHits = new Subject<{ name: string; timestamp: Timestamp }>();
const allMemos = new Subject<{
name: string;
time: number;
details?: IDetails;
}>();
const allSummaries = new Subject<{ name: string; summary: ISummary }>();
const defaultDeps = {
timestamp: () => process.hrtime(),
timestampDiff: (timestamp: Timestamp) => process.hrtime(timestamp),
};
function createInitialState(deps = defaultDeps): IState {
const stateByTag = new Map<string, ITagState>();
const createStateForTag = (): ITagState => {
return {};
};
const self = Object.freeze({
hit: (name: string) => {
const state = stateByTag.get(name) || createStateForTag();
state.hitTime = deps.timestamp();
allHits.next({
name,
timestamp: state.hitTime,
});
stateByTag.set(name, state);
},
memo: (
name: string,
details?: IDetails,
determine: (metric: number) => number = (value) => value
): number | null => {
const state = stateByTag.get(name);
if (!state || !state.hitTime) {
return null;
}
const timeSinceHit = determine(
timestampToTime(deps.timestampDiff(state.hitTime))
);
allMemos.next({
name,
time: timeSinceHit,
...(details && {
details,
}),
});
stateByTag.set(name, state);
return timeSinceHit;
},
});
return self;
}
function isMatch(name: string, to: string | RegExp) {
return (
(typeof to === 'string' && to === name) ||
(typeof to === 'object' && to.test(name))
);
}
function createSummary(name: string | RegExp): Observable<ISummary> {
const MAX_MEMOS = 100;
return allMemos.asObservable().pipe(
//
filter((item) => isMatch(item.name, name)),
scan(
(acc, item) => {
const memos = [...acc.memos, item.time].slice(-MAX_MEMOS);
const sortedMemos = [...memos];
sortedMemos.sort();
return {
...acc,
memos,
sortedMemos,
max: Math.max(acc.max, item.time),
min: Math.min(acc.min, item.time),
average: Number.isNaN(acc.average)
? item.time
: (acc.average + item.time) / 2,
mostOfTheTimesLessThan: quantile(sortedMemos, 90),
};
},
{
sortedMemos: [] as number[],
memos: [] as number[],
max: 0,
min: Number.MAX_SAFE_INTEGER,
average: NaN,
mostOfTheTimesLessThan: NaN,
}
),
map((item) => ({
numberOfSamples: item.memos.length,
max: item.max,
min: item.min,
average: item.average,
mostOfTheTimesLessThan: item.mostOfTheTimesLessThan,
}))
);
}
interface IState {
hit(name: string): void;
memo(
name: string,
details?: IDetails,
transformTookTime?: (metric: number) => number
): number | null;
}
let cachedState: IState | null = null;
function globalState() {
if (cachedState) {
return cachedState;
}
return (cachedState = createInitialState());
}
export function createState(deps = defaultDeps) {
return createInitialState(deps);
}
export function attach(params: {
name: string;
from: TagNotification;
till: TagNotification;
cb?: (value: number) => void;
details?: IDetails;
transformTimeTook?: (value: number) => number;
state?: IState;
}) {
return <T>(source: Observable<T>) => {
const effectiveState = params.state || createInitialState();
const startOp = () =>
start({ name: params.name, on: params.from }, effectiveState);
const stopOp = () =>
stop(
{
name: params.name,
till: params.till,
cb: params.cb,
transformTookTime: params.transformTimeTook,
details: params.details,
},
effectiveState
);
return source.pipe((stream) => {
if (params.till === 'unsubscribe' && params.from === 'complete') {
return stream.pipe(startOp(), stopOp());
} else {
return stream.pipe(stopOp(), startOp());
}
});
};
}
export function registerStarts(
params: {
name: string;
on: Observable<unknown>;
},
state = globalState()
) {
const hit = () => {
state.hit(params.name);
};
return params.on.pipe(tap(hit), ignoreElements());
}
export function start(
paramsRaw: {
name: string;
on?: TagNotification;
logger?: BasicLogger;
},
state = globalState()
) {
const params = {
on: 'next' as const,
...paramsRaw,
};
return <T>(stream: Observable<T>) => {
const hit = () => {
state.hit(params.name);
};
return stream.pipe(
executeOnNotifications(
[params.on],
hit,
params.logger ?? defaultBasicLogger()
)
);
};
}
export function registerStops(
params: {
name: string;
on: Observable<unknown>;
details?: IDetails;
cb?: (time: number) => void;
transformTookTime?: (time: number) => number;
},
state = globalState()
) {
const setMemo = () => {
const timeTook = state.memo(
params.name,
params.details,
params.transformTookTime
);
if (typeof timeTook === 'number' && params.cb) {
params.cb(timeTook);
}
};
return params.on.pipe(tap(setMemo), ignoreElements());
}
export function stop(
paramsRaw: {
name: string;
till: TagNotification;
details?: IDetails;
cb?: (time: number) => void;
transformTookTime?: (time: number) => number;
logger?: BasicLogger;
},
state = globalState()
) {
const params = paramsRaw;
return <T>(stream: Observable<T>) => {
const setMemo = () => {
const timeTook = state.memo(
params.name,
params.details,
params.transformTookTime
);
if (typeof timeTook === 'number' && params.cb) {
params.cb(timeTook);
}
};
return stream.pipe(
executeOnNotifications(
[params.till],
setMemo,
params.logger ?? defaultBasicLogger()
)
);
};
}
function quantile(
input: number[],
percentile: number,
params?: { sorted: boolean }
) {
const array = params && params.sorted ? input : input.slice().sort();
const index = (percentile / 100) * (array.length - 1);
const i = Math.floor(index);
if (i === index) {
return array[index];
} else {
return array[i] + (array[i + 1] - array[i]) / 2;
}
}
interface ISummary {
numberOfSamples: number;
average: number;
mostOfTheTimesLessThan: number;
min: number;
max: number;
}
export function logSummaries(params: {
name: string;
on?: LogOn;
logger?: Logger;
}) {
const summaries = createSummary(params.name);
const logger = params.logger ?? defaultLogger;
return summaries.pipe(
logEvents({
prefix: `${EOL}🔃 Profiler results for [${params.name}]`,
suffix: [EOL],
logger,
...(params.on && {
on: params.on,
}),
}),
ignoreElements()
);
}
export function timesRegistered(name?: string | RegExp) {
return allMemos.pipe((stream) =>
name ? stream.pipe(filter((item) => isMatch(item.name, name))) : stream
);
}
export function summariesLogged(name?: string) {
return allSummaries.pipe((stream) =>
name ? stream.pipe(filter((item) => isMatch(item.name, name))) : stream
);
}