@report-toolkit/inspector
Version:
See docs at [https://ibm.github.io/report-toolkit](https://ibm.github.io/report-toolkit)
992 lines (873 loc) • 25.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var common = require('@report-toolkit/common');
var Ajv = _interopDefault(require('ajv'));
var ms = _interopDefault(require('ms'));
const debug = common.createDebugger('inspector', 'message');
const {
ERROR,
MULTIPLE_FILEPATHS
} = common.constants;
const compactMessageOptions = common._.omitBy(common._.overEvery([common._.negate(common._.isError), common._.negate(common._.isBoolean), common._.isEmpty]));
class Message {
/**
*
* @param {Partial<MessageOptions>} opts
*/
constructor(opts = {}) {
const {
data,
filepath,
id,
isAggregate,
message,
error,
config,
severity = ERROR
} = compactMessageOptions(opts);
if (message) {
this.message = message.toString();
} else {
debug('creating empty Message from opts:', opts);
}
this.severity = severity;
this.id = id;
if (config) {
this.config = config;
}
if (isAggregate) {
this.filepath = MULTIPLE_FILEPATHS;
} else if (filepath) {
this.filepath = filepath;
}
if (error) {
this.error = error;
}
if (data) {
this.data = data;
}
}
isNonEmpty() {
return Boolean(this.message && this.message.trim());
}
toString() {
return this.message;
}
}
/**
* Create a {@link Message} to be displayed to the user. Used by Rule implementations. Calls without either `message` or a `message` prop on a provided {@link MessageOptions} object will be result in a {@link Message} which will be ignored.}
* @param {RawMessage} message - Message to display to user, or {@link MessageOptions} object with `message` prop
* @param {Partial<MessageOptions>} [opts] - Options
*/
const createMessage = (message, opts = {}) => {
message = common._.isString(message) ? {
message
} : message;
return Object.freeze(new Message({ ...message,
...opts
}));
};
/**
* Allowed extra options when creating a {@link Message}
* @typedef {object} MessageOptions
* @property {object} data - Extra data
* @property {string} filepath - Filepath of associated report, if applicable. Ignored if `isAggregate` is `true`
* @property {string} id - ID of Rule which created the {@link Message}
* @property {boolean} isAggregate - `true` if {@link Message} references multiple files
* @property {Error} error - If present, an `Error` which was thrown by the Rule
* @property {object} config - Rule configuration
* @property {constants.ERROR|constants.WARNING|constants.INFO} severity - The severity of the {@link Message}
* @property {string} message - The message text itself, if first param not passed to {@link Message.createMessage}.
*/
/**
* @typedef {string|Partial<MessageOptions>} RawMessage
*/
/**
* @typedef {import('@report-toolkit/common/src/constants')} constants
*/
const debug$1 = common.createDebugger('inspector', 'rule-config');
const ruleMap = new WeakMap();
class RuleConfig {
/**
*
* @param {import('./rule').Rule} rule
* @param {Object} [rawConfig]
*/
constructor(rule, rawConfig = {}) {
ruleMap.set(this, rule);
if (common._.isArray(rawConfig)) {
rawConfig = rawConfig[1];
}
if (common._.isBoolean(rawConfig)) {
rawConfig = {};
}
if (!common._.isEmpty(rawConfig)) {
debug$1('found raw config %O', rawConfig);
}
this.config = this.validate(rawConfig);
}
get rule() {
return ruleMap.get(this);
}
get id() {
return this.rule.id;
}
/**
* validate the config against meta.schema using ajv
* @todo
* @param {Object} rawConfig
* @returns boolean
*/
validate(rawConfig) {
const config = this.rule.validate(rawConfig);
debug$1(`config for rule ${this.id} OK:`, this.config);
return config;
}
/**
*
* @param {import('rxjs').Observable<import('@report-toolkit/common/src/report').Report>} reports
* @returns {import('rxjs').Observable<import('./message').Message>}
*/
inspect(reports) {
return this.rule.inspect({
config: this.config,
reports
});
}
static create(rule, rawConfig) {
return Object.freeze(new RuleConfig(rule, rawConfig));
}
}
/**
* @type {(rawConfig: object, rule: import('./rule').Rule) => RuleConfig}
*/
const createRuleConfig = common._.curryN(2, common._.flip(RuleConfig.create));
const AJV = common._.once(() => new Ajv({
meta: false,
missingRefs: 'ignore',
useDefaults: true,
validateSchema: false,
verbose: true
}));
const {
WARNING,
SEVERITIES
} = common.constants;
const {
RTKERR_INVALID_RULE_CONFIG,
RTKERR_INVALID_RULE_DEFINITION,
RTKERR_INVALID_SCHEMA,
RTkError
} = common.error;
const {
catchError,
concat,
concatMap,
defer,
filter,
from,
fromAny,
map,
mergeMap,
of,
tap,
throwError
} = common.observable;
const {
kRuleId,
kRuleInspect,
kRuleMeta
} = common.symbols;
const debug$2 = common.createDebugger('inspector', 'rule');
/**
* Map of Rules to config schema validation functions
* @type {WeakMap<Rule,Function>}
*/
const validatorMap = new WeakMap();
/**
* @type {import('ajv').Ajv}
*/
let ajv;
/**
* Operator that catches an Error emitted from a handler function and emits a
* partial `Message` containing the error message, original error, and severity.
* If the `Error` object has a valid `severity` prop, this severity is used;
* otherwise `WARNING` is used.
* @todo WARNING may not be the right default.
* @returns
* {import('rxjs').MonoTypeOperatorFunction<import('./message').RawMessage>}
*/
const catchHandlerError = () => observable => observable.pipe(catchError(error => of({
message: common._.isError(error) ? error.message : String(error),
error,
severity: common._.isError(error) && common._.has('severity', error) && common._.has(
/** @type {any} */
error.severity, SEVERITIES) ?
/** @type {any} */
error.severity : WARNING
})));
/**
* A Rule which can be matched against a Context
*/
class Rule {
/**
* Applies defaults, assigns some metadata.
* @param {RuleDefinition} ruleDef
*/
constructor(ruleDef) {
ruleDef = Rule.applyDefaults(ruleDef);
if (!common._.isFunction(ruleDef.inspect)) {
throw RTkError.create(RTKERR_INVALID_RULE_DEFINITION, `Definition for rule "${ruleDef.id}" must export an "inspect" function`);
}
Object.assign(this, {
[kRuleId]: ruleDef.id,
[kRuleInspect]: ruleDef.inspect,
[kRuleMeta]: ruleDef.meta
});
}
/**
* @type {string}
*/
get id() {
// @ts-ignore
return this[kRuleId];
}
get description() {
return common._.get('docs.description', this.meta);
}
get url() {
return common._.get('docs.url', this.meta);
}
get schema() {
return common._.get('schema', this.meta);
}
/**
* @type {object}
* @todo update with schema for 'meta' prop
*/
get meta() {
// @ts-ignore
return this[kRuleMeta];
}
/**
* Lazily-created function which validates the schema itself when first
* referenced, creates a config-validation function, caches it, then asserts
* any user-supplied config is valid using said function.
* @throws If user-supplied config is invalid.
*/
get validate() {
if (validatorMap.has(this)) {
debug$2(`returning cached validator for rule ${this.id}`);
return validatorMap.get(this);
}
const schema = this.schema;
if (!schema) {
return common._.identity;
}
debug$2(`found schema for rule ${this.id}`, schema);
ajv = ajv || AJV();
const validate = ajv.compile(schema);
if (ajv.errors) {
throw RTkError.create(RTKERR_INVALID_SCHEMA, `Schema for rule ${this.id} is invalid: ${ajv.errorsText()}`);
}
validatorMap.set(this,
/** @param {object} config */
config => {
debug$2(`validating ${this.id} with config`, config);
validate(config);
if (validate.errors) {
const errors = ajv.errorsText(validate.errors, {
dataVar: 'config'
});
throw RTkError.create(RTKERR_INVALID_RULE_CONFIG, `Invalid configuration for rule "${this.id}": ${errors}`, {
url: this.url
});
}
return config;
});
return validatorMap.get(this);
}
/**
* Calls the `inspect()` function of a Rule impl, which will return one or
* more "handler" functions. Note `inspect()` might return a `Promise` which
* resolves to the "handler" functions.
* @param {Object} [config] Optional rule-specific config
* @returns {Promise<Object|Function>}
*/
async handlers(config = {}) {
// @ts-ignore
return this[kRuleInspect].call(null, config);
}
/**
* Given a stream of Report objects and an optional configuration, execute the
* `inspect()` function of the rule definition, which should return a "next"
* handler function, or an object having handler function props `next` and
* `complete`.
* 1. Normalize the result of the `inspect()` so we can make assumptions about
* the shape of the returned value.
* 2. For each `Report` (`report`), run the `next` handler as if it returned
* a `Promise`. This handler is passed the `report`, and any `Error`s
* thrown are trapped. The handler may return a string ("message"), a
* partial `Message` object, `Array` thereof, or a `Promise` resolving to
* any of that stuff, or just `undefined` in the case of "nothing to
* mention"
* 3. Return values are correlated with the filepath of the report. Note that
* `Report` objects may not *have* a filepath if they were not loaded from
* file.
* 4. Once all `Report`s have passed through the `next` handler, call the
* `complete` handler. It receives no `report`, and can be used in tandem
* with `next` to perform aggregation. Supports the same return values as
* `next`
* 5. Finally, filter out empty/falsy partial `Message`s (e.g., those without
* actual `string` `message` props), and normalize the `Message` by adding
* relevant metadata (`Rule` ID, user-supplied config used, default
* severity, etc.)
* @param {{reports: import('rxjs').Observable<import('@report-toolkit/common/src/report').Report>, config?: object}} opts
* @returns {import('rxjs').Observable<import('./message').Message>}
*/
inspect({
reports,
config = {}
}) {
return from(this.handlers(config)).pipe(Rule.normalizeHandler(), mergeMap(handler => {
/**
* smite Zalgo by normalizing the return values to Promises
* @param {import('@report-toolkit/common/src/report').Report} report
*/
const next = async report => handler.next(report);
const complete = async () => handler.complete(); // the intent here is to provide the user filepath information
// whenever possible. in the case of Rules using the "complete"
// handler, they may--but not always--be generating a message based
// on the aggregate of several reports. in that case, we cannot
// cross-reference a single filepath. however, if a Rule _throws_
// when inspecting a filepath, we can consider the aggregate to contain
// one less file, because the Rule cannot process the file further.
// ultimately, if the count of non-error-throwing filepaths is equal to
// one (1), we only have a single report file ("aggregated" or not),
// and can then cross-reference it when providing output to the user.
/** @type {string[]} */
const nonThrowingReportFilepaths = [];
const id = this.id;
return concat(reports.pipe(tap(report => {
if (report.filepath) {
nonThrowingReportFilepaths.push(report.filepath);
}
}), concatMap(report => fromAny(next(report)).pipe(catchError(err => {
nonThrowingReportFilepaths.pop();
return throwError(err);
}), catchHandlerError(), map(message => createMessage(message, {
config,
filepath: report.filepath,
id
}))))), defer(() => fromAny(complete())).pipe(catchHandlerError(), map(message => {
const isAggregate = nonThrowingReportFilepaths.length > 1; // this will be ignored by Message if the above is true.
const filepath = nonThrowingReportFilepaths.shift();
return createMessage(message, {
config,
filepath,
id,
isAggregate
});
})));
}), filter(message => message.isNonEmpty()));
}
/**
* Applies defaults to a rule definition during `Rule` construction.
* @param {Partial<RuleDefinition>} ruleDef - Raw rule definition
* @returns {RuleDefinition}
*/
static applyDefaults(ruleDef) {
return common._.defaultsDeep({
meta: {
docs: {}
}
}, ruleDef);
}
/**
* Operator. Given a "handler" (returned by the rule definition's `inspect`
* function), normalize it into an object (since it may be just a function)
* @returns {import('rxjs').OperatorFunction<RuleHandler,RuleHandlerObject>}
*/
static normalizeHandler() {
return ruleHandler$ => ruleHandler$.pipe(map(ruleHandler => common._.isFunction(ruleHandler) ? {
complete: common._.noop,
next: ruleHandler
} : {
complete: ruleHandler.complete || common._.noop,
next: ruleHandler.next
}));
}
/**
* Creates a `Rule` from a user-defined (or builtin) `RuleDefinition`, which
* is the exports of a rule definition file.
* @param {RuleDefinition} ruleDefinition - Rule definition
* @returns {Rule} New rule
*/
static create(ruleDefinition) {
return new Rule(ruleDefinition);
}
/**
* Given a {@link Config}, get associated rule config and create a `RuleConfig`.
*/
toRuleConfig(config) {
return createRuleConfig(common._.get(this.id, common._.get('rules', config)), this);
}
}
const createRule = Rule.create;
/**
* @typedef {Object} RuleDefinition
* @property {object} meta - (schema for `meta` prop)
* @property {RuleDefinitionInspectFunction} inspect - Async function which receives `Context` object
* and optional configuration
* @property {string} id - Unique rule ID
*/
/**
* @typedef {Object} RuleDefinitionMeta
*/
/**
* @typedef {string} RuleDefinitionId
*/
/**
* @typedef {(config?: object) => Promise<RuleHandler>|RuleHandler} RuleDefinitionInspectFunction
*/
/**
* @typedef {RuleHandlerFunction|RuleHandlerObject} RuleHandler
*/
/**
* @typedef {Object} RuleHandlerObject
* @property {RuleHandlerFunction} next
* @property {(() => Promise<import('./message').RawMessage>|import('./message').RawMessage|void)?} complete
*/
/**
* @typedef {(report: import('@report-toolkit/common').Report) => Promise<import('./message').RawMessage>|import('./message').RawMessage|void|import('./message').RawMessage[]} RuleHandlerFunction
*/
const {
INFO
} = common.constants;
const MODE_ALL = 'all';
const MODE_MEAN = 'mean';
const MODE_MIN = 'min';
const MODE_MAX = 'max';
const modeDescriptions = new Map([[MODE_ALL, 'Report'], [MODE_MAX, 'Maximum'], [MODE_MEAN, 'Mean'], [MODE_MIN, 'Minimum']]);
const computations = {
[MODE_MAX]:
/** @param {number[]} usages */
usages => usages.reduce((max, usage) => Math.max(max, usage), 0),
[MODE_MEAN]:
/** @param {number[]} usages */
usages => usages.reduce((acc, value, i, arr) => i === arr.length - 1 ? parseFloat(((acc + value) / arr.length).toFixed(2)) : acc + value, 0),
[MODE_MIN]:
/** @param {number[]} usages */
usages => usages.reduce((acc, value) => Math.min(acc, value), Infinity)
};
/**
* Returns `true` if `usage` is between `min` and `max` inclusive
* @param {number} min
* @param {number} max
* @param {number} usage
*/
const withinRange = (min, max, usage) => usage >= min && usage <= max;
const ok = ({
max,
min,
mode,
usage
}) => {
return {
data: {
max,
min,
mode,
usage
},
message: `${modeDescriptions.get(mode)} CPU consumption percent (${usage}%) is within the allowed range of ${min}-${max}%`,
severity: INFO
};
};
const fail = ({
max,
min,
mode,
usage
}) => {
return {
data: {
max,
min,
mode,
usage
},
message: `${modeDescriptions.get(mode)} CPU consumption percent (${usage}%) is outside the allowed range of ${min}-${max}%`
};
};
const id = 'cpu-usage';
/**
* @type {import('../rule').RuleDefinitionMeta}
*/
const meta = {
docs: {
category: 'resource',
description: 'Assert CPU usage % is within a range',
url: 'https://more-information-for-this-rule'
},
constants: {
MODE_ALL,
MODE_MAX,
MODE_MEAN,
MODE_MIN
},
schema: {
additionalProperties: false,
properties: {
max: {
default: 50,
minimum: 0,
type: 'integer'
},
min: {
default: 0,
minimum: 0,
type: 'integer'
},
mode: {
default: 'mean',
enum: ['mean', 'min', 'max', 'all'],
type: 'string'
}
},
type: 'object'
}
};
/**
* @type {import('../rule').RuleDefinitionInspectFunction}
*/
const inspect = (config = {}) => {
let {
max,
min,
mode
} = config;
min = min || 0;
max = max || 50;
mode = mode || 'mean';
/**
* @type {number[]}
*/
const usages = [];
return {
complete() {
if (mode !== MODE_ALL && usages.length) {
const usage = computations[mode](usages);
return withinRange(min, max, usage) ? ok({
max,
min,
mode,
usage
}) : fail({
max,
min,
mode,
usage
});
}
},
next(context) {
if (!context.header.cpus) {
throw new Error(`Property "header.cpus" missing in report at ${context.filepath}; cannot compute CPU usage.`);
}
const usage = parseFloat((context.resourceUsage.cpuConsumptionPercent / context.header.cpus.length).toFixed(2));
if (mode === MODE_ALL) {
return withinRange(min, max, usage) ? ok({
max,
min,
mode,
usage
}) : fail({
max,
min,
mode,
usage
});
}
usages.push(usage);
}
};
};
var cpuUsage = /*#__PURE__*/Object.freeze({
__proto__: null,
id: id,
meta: meta,
inspect: inspect
});
const VERSION_REGEXP = /(\d+(?:\.\d+)+[a-z]?)/;
/**
* @type {import('../rule').RuleDefinitionInspectFunction}
*/
const inspect$1 = (config = {}) => {
const ignoredComponents = new Set(config.ignore || []);
return context => {
const {
header,
sharedObjects
} = context;
return Object.keys(header.componentVersions).filter(component => !ignoredComponents.has(component)) // this should be a flatMap()
.reduce((acc, component) => {
const version = header.componentVersions[component];
return [...acc, sharedObjects.filter(
/** @param {string} filepath */
filepath => {
const sharedVersion = VERSION_REGEXP.exec(filepath);
return filepath.includes(component) && sharedVersion && sharedVersion[1] !== version;
}).map(
/** @param {string} filepath */
filepath => `Custom shared library at ${filepath} in use conflicting with ${component}@${version}`)];
}, []);
};
};
const id$1 = 'library-mismatch';
const meta$1 = {
docs: {
category: 'runtime',
description: 'Identify potential shared library version mismatches',
url: 'https://more-information-for-this-rule'
},
schema: {
additionalProperties: false,
properties: {
ignore: {
items: {
type: 'string'
},
minItems: 1,
type: 'array'
}
},
type: 'object'
}
};
var libraryMismatch = /*#__PURE__*/Object.freeze({
__proto__: null,
inspect: inspect$1,
id: id$1,
meta: meta$1
});
const id$2 = 'long-timeout';
const meta$2 = {
docs: {
category: 'event-queue',
description: 'Warn about far-future callbacks in timeout queue',
url: 'https://more-information-for-this-rule'
},
schema: {
additionalProperties: false,
properties: {
timeout: {
default: 10000,
minimum: 0,
type: ['integer', 'string']
}
},
type: 'object'
}
}; // @ts-ignore
const inspect$2 = ({
timeout
} = {}) => {
timeout = typeof timeout === 'string' ? ms(timeout) : timeout;
return context => {
const {
libuv
} = context;
return libuv.filter(handle => handle.type === 'timer' && !handle.expired && handle.is_referenced && handle.firesInMsFromNow >= timeout).map(handle => `libuv handle at address ${handle.address} is a timer with future expiry in ${ms(handle.firesInMsFromNow)}`);
};
};
var longTimeout = /*#__PURE__*/Object.freeze({
__proto__: null,
id: id$2,
meta: meta$2,
inspect: inspect$2
});
const MODE_ALL$1 = 'all';
const MODE_MEAN$1 = 'mean';
const MODE_MIN$1 = 'min';
const MODE_MAX$1 = 'max';
const hrMap = {
[MODE_ALL$1]: 'Report',
[MODE_MAX$1]: 'Maximum',
[MODE_MEAN$1]: 'Mean',
[MODE_MIN$1]: 'Minimum'
};
const computations$1 = {
[MODE_MAX$1]: usages => usages.reduce((acc, value) => Math.max(acc, value), 0),
[MODE_MEAN$1]: usages => usages.reduce((acc, value, i, arr) => i === arr.length - 1 ? (acc + value) / arr.length : acc + value, 0),
[MODE_MIN$1]: usages => usages.reduce((acc, value) => Math.min(acc, value), Infinity)
};
const withinRange$1 = (min, max, usage) => usage >= min && usage <= max;
const ok$1 = ({
max,
min,
mode
}, usage) => {
return {
data: {
max,
min,
mode,
usage
},
message: `${hrMap[mode]} memory usage percent (${usage}%) is within the allowed range ${min}-${max}%`,
severity: 'info'
};
};
const fail$1 = ({
max,
min,
mode
}, usage) => {
return {
data: {
max,
min,
mode,
usage
},
message: `${hrMap[mode]} memory usage percent (${usage}%) is outside the allowed range ${min}-${max}%`
};
}; // @ts-ignore
const inspect$3 = ({
max,
min,
mode
} = {}) => {
const usages = [];
return {
complete() {
if (mode !== MODE_ALL$1) {
const usage = computations$1[mode](usages);
return withinRange$1(min, max, usage) ? ok$1({
max,
min,
mode
}, usage) : fail$1({
max,
min,
mode
}, usage);
}
},
next(context) {
const usage = parseFloat((context.javascriptHeap.totalMemory / context.javascriptHeap.availableMemory * 100).toFixed(2));
if (mode === MODE_ALL$1) {
return withinRange$1(min, max, usage) ? ok$1({
max,
min,
mode
}, usage) : fail$1({
max,
min,
mode
}, usage);
} else {
usages.push(usage);
}
}
};
};
const id$3 = 'memory-usage';
/**
* @type {any}
*/
const meta$3 = {
docs: {
category: 'resource',
description: 'Assert memory usage % is within a range',
url: 'https://more-information-for-this-rule'
},
constants: {
MODE_ALL: MODE_ALL$1,
MODE_MAX: MODE_MAX$1,
MODE_MEAN: MODE_MEAN$1,
MODE_MIN: MODE_MIN$1
},
schema: {
additionalProperties: false,
properties: {
max: {
default: 50,
minimum: 0,
type: 'integer'
},
min: {
default: 0,
minimum: 0,
type: 'integer'
},
mode: {
default: 'mean',
enum: ['mean', 'min', 'max', 'all'],
type: 'string'
}
},
type: 'object'
}
};
var memoryUsage = /*#__PURE__*/Object.freeze({
__proto__: null,
inspect: inspect$3,
id: id$3,
meta: meta$3
});
const rules = [cpuUsage, libraryMismatch, longTimeout, memoryUsage];
const {
RTKERR_INVALID_REPORT
} = common.error;
const {
mergeMap: mergeMap$1,
pipeIf,
map: map$1,
switchMapTo,
throwRTkError
} = common.observable;
const debug$3 = common.createDebugPipe('inspector');
/**
* Pipes `Report` objects into each `RuleConfig`, then filters on severity level.
* @param {import('rxjs').Observable<import('@report-toolkit/common').Report>} reports - Stream of Report objects
* @returns {import('rxjs').OperatorFunction<RuleConfig,Message>}
*/
const inspectReports = reports => ruleConfigs => ruleConfigs.pipe(mergeMap$1(ruleConfig => ruleConfig.inspect(reports)), debug$3(msg => `received message ${JSON.stringify(msg)}`));
/**
*
* @param {Partial<ToReportFromObjectOptions>} [opts]
* @returns {import('rxjs').OperatorFunction<object,Readonly<import('@report-toolkit/common').Report>>}
*/
function toReportFromObject({
showSecretsUnsafe = false
} = {}) {
return observable => observable.pipe(pipeIf(showSecretsUnsafe !== true, map$1(obj => obj.rawReport ? { ...obj,
rawReport: common.redact(obj.rawReport)
} : {
rawReport: common.redact(obj)
})), pipeIf(({
rawReport
}) => !common.isReportLike(rawReport), switchMapTo(throwRTkError(RTKERR_INVALID_REPORT, 'Encountered a thing that does not look like a report!'))), map$1(({
filepath,
rawReport
}) => common.createReport(rawReport, filepath)));
}
/**
* @typedef {import('./rule').RuleDefinition} RuleDefinition
*/
/**
* @typedef {object} ToReportFromObjectOptions
* @property {boolean} showSecretsUnsafe - If `true`, do not redact secrets
*/
exports.Message = Message;
exports.Rule = Rule;
exports.RuleConfig = RuleConfig;
exports.createRule = createRule;
exports.createRuleConfig = createRuleConfig;
exports.inspectReports = inspectReports;
exports.rules = rules;
exports.toReportFromObject = toReportFromObject;
//# sourceMappingURL=report-toolkit-inspector.cjs.js.map