UNPKG

@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
'use strict'; 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