packages/aws-cdk-lib/aws-logs/lib/pattern.ts (205 lines of code) (raw):

import { UnscopedValidationError } from '../../core'; // Implementation of metric patterns /** * Interface for objects that can render themselves to log patterns. */ export interface IFilterPattern { readonly logPatternString: string; } /** * Base class for patterns that only match JSON log events. */ export abstract class JsonPattern implements IFilterPattern { // This is a separate class so we have some type safety where users can't // combine text patterns and JSON patterns with an 'and' operation. constructor(public readonly jsonPatternString: string) { } public get logPatternString(): string { return '{ ' + this.jsonPatternString + ' }'; } } /** * A collection of static methods to generate appropriate ILogPatterns */ export class FilterPattern { /** * Use the given string as log pattern. * * See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html * for information on writing log patterns. * * @param logPatternString The pattern string to use. */ public static literal(logPatternString: string): IFilterPattern { return new LiteralLogPattern(logPatternString); } /** * A log pattern that matches all events. */ public static allEvents(): IFilterPattern { return new LiteralLogPattern(''); } /** * A log pattern that matches if all the strings given appear in the event. * * @param terms The words to search for. All terms must match. */ public static allTerms(...terms: string[]): IFilterPattern { return new TextLogPattern([terms]); } /** * A log pattern that matches if any of the strings given appear in the event. * * @param terms The words to search for. Any terms must match. */ public static anyTerm(...terms: string[]): IFilterPattern { return new TextLogPattern(terms.map(t => [t])); } /** * A log pattern that matches if any of the given term groups matches the event. * * A term group matches an event if all the terms in it appear in the event string. * * @param termGroups A list of term groups to search for. Any one of the clauses must match. */ public static anyTermGroup(...termGroups: string[][]): IFilterPattern { return new TextLogPattern(termGroups); } /** * A JSON log pattern that compares string values. * * This pattern only matches if the event is a JSON event, and the indicated field inside * compares with the string value. * * Use '$' to indicate the root of the JSON structure. The comparison operator can only * compare equality or inequality. The '*' wildcard may appear in the value may at the * start or at the end. * * For more information, see: * * https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html * * @param jsonField Field inside JSON. Example: "$.myField" * @param comparison Comparison to carry out. Either = or !=. * @param value The string value to compare to. May use '*' as wildcard at start or end of string. */ public static stringValue(jsonField: string, comparison: string, value: string): JsonPattern { return new JSONStringPattern(jsonField, comparison, value); } /** * A JSON log pattern that compares numerical values. * * This pattern only matches if the event is a JSON event, and the indicated field inside * compares with the value in the indicated way. * * Use '$' to indicate the root of the JSON structure. The comparison operator can only * compare equality or inequality. The '*' wildcard may appear in the value may at the * start or at the end. * * For more information, see: * * https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html * * @param jsonField Field inside JSON. Example: "$.myField" * @param comparison Comparison to carry out. One of =, !=, <, <=, >, >=. * @param value The numerical value to compare to */ public static numberValue(jsonField: string, comparison: string, value: number): JsonPattern { return new JSONNumberPattern(jsonField, comparison, value); } /** * A JSON log pattern that compares against a Regex values. * * This pattern only matches if the event is a JSON event, and the indicated field inside * compares with the regex value. * * Use '$' to indicate the root of the JSON structure. The comparison operator can only * compare equality or inequality. * * For more information, see: * * https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html * * @param jsonField Field inside JSON. Example: "$.myField" * @param comparison Comparison to carry out. Either = or !=. * @param value The regex value to compare to. */ public static regexValue(jsonField: string, comparison: string, value: string): JsonPattern { return new JSONRegexPattern(jsonField, comparison, value); } /** * A JSON log pattern that matches if the field exists and has the special value 'null'. * * @param jsonField Field inside JSON. Example: "$.myField" */ public static isNull(jsonField: string): JsonPattern { return new JSONPostfixPattern(jsonField, 'IS NULL'); } /** * A JSON log pattern that matches if the field does not exist. * * @param jsonField Field inside JSON. Example: "$.myField" */ public static notExists(jsonField: string): JsonPattern { return new JSONPostfixPattern(jsonField, 'NOT EXISTS'); } /** * A JSON log patter that matches if the field exists. * * This is a readable convenience wrapper over 'field = *' * * @param jsonField Field inside JSON. Example: "$.myField" */ public static exists(jsonField: string): JsonPattern { return new JSONStringPattern(jsonField, '=', '*'); } /** * A JSON log pattern that matches if the field exists and equals the boolean value. * * @param jsonField Field inside JSON. Example: "$.myField" * @param value The value to match */ public static booleanValue(jsonField: string, value: boolean): JsonPattern { return new JSONPostfixPattern(jsonField, value ? 'IS TRUE' : 'IS FALSE'); } /** * A JSON log pattern that matches if all given JSON log patterns match */ public static all(...patterns: JsonPattern[]): JsonPattern { if (patterns.length === 0) { throw new UnscopedValidationError('Must supply at least one pattern, or use allEvents() to match all events.'); } if (patterns.length === 1) { return patterns[0]; } return new JSONAggregatePattern('&&', patterns); } /** * A JSON log pattern that matches if any of the given JSON log patterns match */ public static any(...patterns: JsonPattern[]): JsonPattern { if (patterns.length === 0) { throw new UnscopedValidationError('Must supply at least one pattern'); } if (patterns.length === 1) { return patterns[0]; } return new JSONAggregatePattern('||', patterns); } /** * A space delimited log pattern matcher. * * The log event is divided into space-delimited columns (optionally * enclosed by "" or [] to capture spaces into column values), and names * are given to each column. * * '...' may be specified once to match any number of columns. * * Afterwards, conditions may be added to individual columns. * * @param columns The columns in the space-delimited log stream. */ public static spaceDelimited(...columns: string[]): SpaceDelimitedTextPattern { return SpaceDelimitedTextPattern.construct(columns); } } /** * Use a string literal as a log pattern */ class LiteralLogPattern implements IFilterPattern { constructor(public readonly logPatternString: string) { } } /** * Search for a set of set of terms */ class TextLogPattern implements IFilterPattern { public readonly logPatternString: string; constructor(clauses: string[][]) { const quotedClauses = clauses.map(terms => terms.map(quoteTerm).join(' ')); if (quotedClauses.length === 1) { this.logPatternString = quotedClauses[0]; } else { this.logPatternString = quotedClauses.map(alt => '?' + alt).join(' '); } } } /** * A string comparison for JSON values */ class JSONStringPattern extends JsonPattern { public constructor(jsonField: string, comparison: string, value: string) { comparison = validateStringOperator(comparison); super(`${jsonField} ${comparison} ${quoteTerm(value)}`); } } /** * A regex comparison for JSON patterns */ class JSONRegexPattern extends JsonPattern { public constructor(jsonField: string, comparison: string, value: string) { // No validation, we assume these are generated by trusted factory functions super(`${jsonField} ${comparison} %${value}%`); } } /** * A number comparison for JSON values */ class JSONNumberPattern extends JsonPattern { public constructor(jsonField: string, comparison: string, value: number) { comparison = validateNumericalOperator(comparison); super(`${jsonField} ${comparison} ${value}`); } } /** * A postfix operator for JSON patterns */ class JSONPostfixPattern extends JsonPattern { public constructor(jsonField: string, postfix: string) { // No validation, we assume these are generated by trusted factory functions super(`${jsonField} ${postfix}`); } } /** * Combines multiple other JSON patterns with an operator */ class JSONAggregatePattern extends JsonPattern { public constructor(operator: string, patterns: JsonPattern[]) { if (operator !== '&&' && operator !== '||') { throw new UnscopedValidationError('Operator must be one of && or ||'); } const clauses = patterns.map(p => '(' + p.jsonPatternString + ')'); super(clauses.join(` ${operator} `)); } } export type RestrictionMap = { [column: string]: ColumnRestriction[] }; const COL_ELLIPSIS = '...'; /** * Space delimited text pattern */ export class SpaceDelimitedTextPattern implements IFilterPattern { /** * Construct a new instance of a space delimited text pattern * * Since this class must be public, we can't rely on the user only creating it through * the `LogPattern.spaceDelimited()` factory function. We must therefore validate the * argument in the constructor. Since we're returning a copy on every mutation, and we * don't want to re-validate the same things on every construction, we provide a limited * set of mutator functions and only validate the new data every time. */ public static construct(columns: string[]) { // Validation happens here because a user could instantiate this object directly without // going through the factory for (const column of columns) { if (!validColumnName(column)) { throw new UnscopedValidationError(`Invalid column name: ${column}`); } } if (sum(columns.map(c => c === COL_ELLIPSIS ? 1 : 0)) > 1) { throw new UnscopedValidationError("Can use at most one '...' column"); } return new SpaceDelimitedTextPattern(columns, {}); } // TODO: Temporarily changed from private to protected to unblock build. We need to think // about how to handle jsii types with private constructors. protected constructor(private readonly columns: string[], private readonly restrictions: RestrictionMap) { // Private constructor so we validate in the .construct() factory function } /** * Restrict where the pattern applies */ public whereString(columnName: string, comparison: string, value: string): SpaceDelimitedTextPattern { if (columnName === COL_ELLIPSIS) { throw new UnscopedValidationError("Can't use '...' in a restriction"); } if (this.columns.indexOf(columnName) === -1) { throw new UnscopedValidationError(`Column in restrictions that is not in columns: ${columnName}`); } comparison = validateStringOperator(comparison); return new SpaceDelimitedTextPattern(this.columns, this.addRestriction(columnName, { comparison, stringValue: value, })); } /** * Restrict where the pattern applies */ public whereNumber(columnName: string, comparison: string, value: number): SpaceDelimitedTextPattern { if (columnName === COL_ELLIPSIS) { throw new UnscopedValidationError("Can't use '...' in a restriction"); } if (this.columns.indexOf(columnName) === -1) { throw new UnscopedValidationError(`Column in restrictions that is not in columns: ${columnName}`); } comparison = validateNumericalOperator(comparison); return new SpaceDelimitedTextPattern(this.columns, this.addRestriction(columnName, { comparison, numberValue: value, })); } public get logPatternString(): string { return '[' + this.columns.map(this.columnExpression.bind(this)).join(', ') + ']'; } /** * Return the column expression for the given column */ private columnExpression(column: string) { const restrictions = this.restrictions[column]; if (!restrictions) { return column; } return restrictions.map(r => renderRestriction(column, r)).join(' && '); } /** * Make a copy of the current restrictions and add one */ private addRestriction(columnName: string, restriction: ColumnRestriction) { const ret: RestrictionMap = {}; for (const key of Object.keys(this.restrictions)) { ret[key] = this.restrictions[key].slice(); } if (!(columnName in ret)) { ret[columnName] = []; } ret[columnName].push(restriction); return ret; } } export interface ColumnRestriction { /** * Comparison operator to use */ readonly comparison: string; /** * String value to compare to * * Exactly one of 'stringValue' and 'numberValue' must be set. */ readonly stringValue?: string; /** * Number value to compare to * * Exactly one of 'stringValue' and 'numberValue' must be set. */ readonly numberValue?: number; } /** * Quote a term for use in a pattern expression * * It's never wrong to quote a string term, and required if the term * contains non-alphanumerical characters, so we just always do it. * * Inner double quotes are escaped using a backslash. */ function quoteTerm(term: string): string { return '"' + term.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; } /** * Return whether the given column name is valid in a space-delimited table */ function validColumnName(column: string) { return column === COL_ELLIPSIS || /^[a-zA-Z0-9_-]+$/.exec(column); } /** * Validate and normalize the string comparison operator * * Correct for a common typo/confusion, treat '==' as '=' */ function validateStringOperator(operator: string) { if (operator === '==') { operator = '='; } if (operator !== '=' && operator !== '!=') { throw new UnscopedValidationError(`Invalid comparison operator ('${operator}'), must be either '=' or '!='`); } return operator; } const VALID_OPERATORS = ['=', '!=', '<', '<=', '>', '>=']; /** * Validate and normalize numerical comparison operators * * Correct for a common typo/confusion, treat '==' as '=' */ function validateNumericalOperator(operator: string) { // Correct for a common typo, treat '==' as '=' if (operator === '==') { operator = '='; } if (VALID_OPERATORS.indexOf(operator) === -1) { throw new UnscopedValidationError(`Invalid comparison operator ('${operator}'), must be one of ${VALID_OPERATORS.join(', ')}`); } return operator; } /** * Render a table restriction */ function renderRestriction(column: string, restriction: ColumnRestriction) { if (restriction.numberValue !== undefined) { return `${column} ${restriction.comparison} ${restriction.numberValue}`; } else if (restriction.stringValue) { return `${column} ${restriction.comparison} ${quoteTerm(restriction.stringValue)}`; } else { throw new UnscopedValidationError('Invalid restriction'); } } function sum(xs: number[]): number { return xs.reduce((a, c) => a + c, 0); }