diff --git a/src/core/server/logging/layouts/conversions/context.ts b/src/core/server/logging/layouts/conversions/context.ts new file mode 100644 index 0000000000000..d1fa9ca84f555 --- /dev/null +++ b/src/core/server/logging/layouts/conversions/context.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; + +import { Conversion } from './type'; +import { LogRecord } from '../../log_record'; + +export const ContextConversion: Conversion = { + pattern: /{context}/gi, + formatter(record: LogRecord, highlight: boolean) { + let message = record.context; + if (highlight) { + message = chalk.magenta(message); + } + return message; + }, +}; diff --git a/src/core/server/logging/layouts/conversions/level.ts b/src/core/server/logging/layouts/conversions/level.ts new file mode 100644 index 0000000000000..02ed86dd2c24f --- /dev/null +++ b/src/core/server/logging/layouts/conversions/level.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; + +import { Conversion } from './type'; +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; + +const LEVEL_COLORS = new Map([ + [LogLevel.Fatal, chalk.red], + [LogLevel.Error, chalk.red], + [LogLevel.Warn, chalk.yellow], + [LogLevel.Debug, chalk.green], + [LogLevel.Trace, chalk.blue], +]); + +export const LevelConversion: Conversion = { + pattern: /{level}/gi, + formatter(record: LogRecord, highlight: boolean) { + let message = record.level.id.toUpperCase().padEnd(5); + if (highlight && LEVEL_COLORS.has(record.level)) { + const color = LEVEL_COLORS.get(record.level)!; + message = color(message); + } + return message; + }, +}; diff --git a/src/core/server/logging/layouts/conversions/message.ts b/src/core/server/logging/layouts/conversions/message.ts new file mode 100644 index 0000000000000..b95a89b12b780 --- /dev/null +++ b/src/core/server/logging/layouts/conversions/message.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Conversion } from './type'; +import { LogRecord } from '../../log_record'; + +export const MessageConversion: Conversion = { + pattern: /{message}/gi, + formatter(record: LogRecord) { + // Error stack is much more useful than just the message. + return (record.error && record.error.stack) || record.message; + }, +}; diff --git a/src/core/server/logging/layouts/conversions/meta.ts b/src/core/server/logging/layouts/conversions/meta.ts new file mode 100644 index 0000000000000..f6d4557e0db53 --- /dev/null +++ b/src/core/server/logging/layouts/conversions/meta.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Conversion } from './type'; +import { LogRecord } from '../../log_record'; + +export const MetaConversion: Conversion = { + pattern: /{meta}/gi, + formatter(record: LogRecord) { + return record.meta ? `[${JSON.stringify(record.meta)}]` : ''; + }, +}; diff --git a/src/core/server/logging/layouts/conversions/pid.ts b/src/core/server/logging/layouts/conversions/pid.ts new file mode 100644 index 0000000000000..0fcdd93fcda0c --- /dev/null +++ b/src/core/server/logging/layouts/conversions/pid.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Conversion } from './type'; +import { LogRecord } from '../../log_record'; + +export const PidConversion: Conversion = { + pattern: /{pid}/gi, + formatter(record: LogRecord) { + return String(record.pid); + }, +}; diff --git a/src/core/server/logging/layouts/conversions/timestamp.ts b/src/core/server/logging/layouts/conversions/timestamp.ts new file mode 100644 index 0000000000000..417b8d8cb8191 --- /dev/null +++ b/src/core/server/logging/layouts/conversions/timestamp.ts @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import moment from 'moment-timezone'; + +import { Conversion } from './type'; +import { LogRecord } from '../../log_record'; + +const timestampRegExp = /{timestamp({([^}]+)})?({([^}]+)})?}/gi; + +const formats = { + ISO8601: 'ISO8601', + ISO8601_TZ: 'ISO8601_TZ', + ABSOLUTE: 'ABSOLUTE', + UNIX: 'UNIX', + UNIX_MILLIS: 'UNIX_MILLIS', +}; + +function formatDate(date: Date, dateFormat: string = formats.ISO8601, timezone?: string): string { + const momentDate = moment(date); + if (timezone) { + momentDate.tz(timezone); + } + switch (dateFormat) { + case formats.ISO8601: + return momentDate.toISOString(); + case formats.ISO8601_TZ: + return momentDate.format('YYYY-MM-DDTHH:mm:ss,SSSZZ'); + case formats.ABSOLUTE: + return momentDate.format('HH:mm:ss,SSS'); + case formats.UNIX: + return momentDate.format('X'); + case formats.UNIX_MILLIS: + return momentDate.format('x'); + default: + throw new Error(`Unknown format: ${dateFormat}`); + } +} + +function validateDateFormat(input: string) { + if (Reflect.has(formats, input)) return; + throw new Error( + `Date format expected one of ${Reflect.ownKeys(formats).join(', ')}, but given: ${input}` + ); +} + +function validateTimezone(timezone: string) { + if (moment.tz.zone(timezone)) return; + throw new Error(`Unknown timezone: ${timezone}`); +} + +function validate(rawString: string) { + for (const matched of rawString.matchAll(timestampRegExp)) { + const [, , dateFormat, , timezone] = matched; + + if (dateFormat) { + validateDateFormat(dateFormat); + } + if (timezone) { + validateTimezone(timezone); + } + } +} + +export const TimestampConversion: Conversion = { + pattern: timestampRegExp, + formatter(record: LogRecord, highlight: boolean, ...matched: string[]) { + const [, , dateFormat, , timezone] = matched; + return formatDate(record.timestamp, dateFormat, timezone); + }, + validate, +}; diff --git a/src/core/server/logging/layouts/conversions/type.ts b/src/core/server/logging/layouts/conversions/type.ts new file mode 100644 index 0000000000000..34a6475138814 --- /dev/null +++ b/src/core/server/logging/layouts/conversions/type.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { LogRecord } from 'kibana/server'; + +export interface Conversion { + pattern: RegExp; + formatter: (record: LogRecord, highlight: boolean) => string; + validate?: (input: string) => void; +} diff --git a/src/core/server/logging/layouts/pattern_layout.test.ts b/src/core/server/logging/layouts/pattern_layout.test.ts index 9ff0c337fe0ac..ebff5967c8e8a 100644 --- a/src/core/server/logging/layouts/pattern_layout.test.ts +++ b/src/core/server/logging/layouts/pattern_layout.test.ts @@ -20,7 +20,7 @@ import { stripAnsiSnapshotSerializer } from '../../../test_helpers/strip_ansi_snapshot_serializer'; import { LogLevel } from '../log_level'; import { LogRecord } from '../log_record'; -import { PatternLayout } from './pattern_layout'; +import { PatternLayout, patternSchema } from './pattern_layout'; const records: LogRecord[] = [ { @@ -192,3 +192,155 @@ test('`format()` allows specifying pattern with meta.', () => { `"context-[{\\"from\\":\\"v7\\",\\"to\\":\\"v8\\"}]-message"` ); }); + +describe('format', () => { + describe('timestamp', () => { + const record = { + context: 'context', + level: LogLevel.Debug, + message: 'message', + timestamp: new Date(Date.UTC(2012, 1, 1)), + pid: 5355, + }; + it('uses ISO8601 as default', () => { + const layout = new PatternLayout(); + + expect(layout.format(record)).toMatchInlineSnapshot( + `"[2012-02-01T00:00:00.000Z][DEBUG][context] message"` + ); + }); + describe('supports specifying a predefined format', () => { + it('ISO8601', () => { + const layout = new PatternLayout('[{timestamp{ISO8601}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot( + `"[2012-02-01T00:00:00.000Z][context]"` + ); + }); + + it('ISO8601_TZ', () => { + const layout = new PatternLayout( + '[{timestamp{ISO8601_TZ}{America/Los_Angeles}}][{context}]' + ); + + expect(layout.format(record)).toMatchInlineSnapshot( + `"[2012-01-31T16:00:00,000-0800][context]"` + ); + }); + + it('ABSOLUTE', () => { + const layout = new PatternLayout('[{timestamp{ABSOLUTE}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[01:00:00,000][context]"`); + }); + + it('UNIX', () => { + const layout = new PatternLayout('[{timestamp{UNIX}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[1328054400][context]"`); + }); + + it('UNIX_MILLIS', () => { + const layout = new PatternLayout('[{timestamp{UNIX_MILLIS}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[1328054400000][context]"`); + }); + }); + + describe('supports specifying a predefined format and timezone', () => { + it('ISO8601', () => { + const layout = new PatternLayout('[{timestamp{ISO8601}{America/Los_Angeles}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot( + `"[2012-02-01T00:00:00.000Z][context]"` + ); + }); + + it('ISO8601_TZ', () => { + const layout = new PatternLayout( + '[{timestamp{ISO8601_TZ}{America/Los_Angeles}}][{context}]' + ); + + expect(layout.format(record)).toMatchInlineSnapshot( + `"[2012-01-31T16:00:00,000-0800][context]"` + ); + }); + + it('ABSOLUTE', () => { + const layout = new PatternLayout('[{timestamp{ABSOLUTE}{America/Los_Angeles}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[16:00:00,000][context]"`); + }); + + it('UNIX', () => { + const layout = new PatternLayout('[{timestamp{UNIX}{America/Los_Angeles}}][{context}]'); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[1328054400][context]"`); + }); + + it('UNIX_MILLIS', () => { + const layout = new PatternLayout( + '[{timestamp{UNIX_MILLIS}{America/Los_Angeles}}][{context}]' + ); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[1328054400000][context]"`); + }); + }); + it('formats several conversions patterns correctly', () => { + const layout = new PatternLayout( + '[{timestamp{ABSOLUTE}{America/Los_Angeles}}][{context}][{timestamp{UNIX}}]' + ); + + expect(layout.format(record)).toMatchInlineSnapshot(`"[16:00:00,000][context][1328054400]"`); + }); + }); +}); + +describe('schema', () => { + describe('pattern', () => { + describe('{timestamp}', () => { + it('does not fail when {timestamp} not present', () => { + expect(patternSchema.validate('')).toBe(''); + expect(patternSchema.validate('{pid}')).toBe('{pid}'); + }); + + it('does not fail on {timestamp} without params', () => { + expect(patternSchema.validate('{timestamp}')).toBe('{timestamp}'); + expect(patternSchema.validate('{timestamp}}')).toBe('{timestamp}}'); + expect(patternSchema.validate('{{timestamp}}')).toBe('{{timestamp}}'); + }); + + it('does not fail on {timestamp} with predefined date format', () => { + expect(patternSchema.validate('{timestamp{ISO8601}}')).toBe('{timestamp{ISO8601}}'); + }); + + it('does not fail on {timestamp} with predefined date format and valid timezone', () => { + expect(patternSchema.validate('{timestamp{ISO8601_TZ}{Europe/Berlin}}')).toBe( + '{timestamp{ISO8601_TZ}{Europe/Berlin}}' + ); + }); + + it('fails on {timestamp} with unknown date format', () => { + expect(() => + patternSchema.validate('{timestamp{HH:MM:SS}}') + ).toThrowErrorMatchingInlineSnapshot( + `"Date format expected one of ISO8601, ISO8601_TZ, ABSOLUTE, UNIX, UNIX_MILLIS, but given: HH:MM:SS"` + ); + }); + + it('fails on {timestamp} with predefined date format and invalid timezone', () => { + expect(() => + patternSchema.validate('{timestamp{ISO8601_TZ}{Europe/Kibana}}') + ).toThrowErrorMatchingInlineSnapshot(`"Unknown timezone: Europe/Kibana"`); + }); + + it('validates several {timestamp} in pattern', () => { + expect(() => + patternSchema.validate('{timestamp{ISO8601_TZ}{Europe/Berlin}}{message}{timestamp{HH}}') + ).toThrowErrorMatchingInlineSnapshot( + `"Date format expected one of ISO8601, ISO8601_TZ, ABSOLUTE, UNIX, UNIX_MILLIS, but given: HH"` + ); + }); + }); + }); +}); diff --git a/src/core/server/logging/layouts/pattern_layout.ts b/src/core/server/logging/layouts/pattern_layout.ts index db6cc64c6b89b..0a2a25a135069 100644 --- a/src/core/server/logging/layouts/pattern_layout.ts +++ b/src/core/server/logging/layouts/pattern_layout.ts @@ -18,53 +18,44 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import chalk from 'chalk'; -import { LogLevel } from '../log_level'; import { LogRecord } from '../log_record'; import { Layout } from './layouts'; -/** - * A set of static constants describing supported parameters in the log message pattern. - */ -const Parameters = Object.freeze({ - Context: '{context}', - Level: '{level}', - Message: '{message}', - Timestamp: '{timestamp}', - Pid: '{pid}', - Meta: '{meta}', -}); - -/** - * Regular expression used to parse log message pattern and fill in placeholders - * with the actual data. - */ -const PATTERN_REGEX = new RegExp(Object.values(Parameters).join('|'), 'gi'); - -/** - * Mapping between `LogLevel` and color that is used to highlight `level` part of - * the log message. - */ -const LEVEL_COLORS = new Map([ - [LogLevel.Fatal, chalk.red], - [LogLevel.Error, chalk.red], - [LogLevel.Warn, chalk.yellow], - [LogLevel.Debug, chalk.green], - [LogLevel.Trace, chalk.blue], -]); +import { Conversion } from './conversions/type'; +import { ContextConversion } from './conversions/context'; +import { LevelConversion } from './conversions/level'; +import { MetaConversion } from './conversions/meta'; +import { MessageConversion } from './conversions/message'; +import { PidConversion } from './conversions/pid'; +import { TimestampConversion } from './conversions/timestamp'; /** * Default pattern used by PatternLayout if it's not overridden in the configuration. */ -const DEFAULT_PATTERN = `[${Parameters.Timestamp}][${Parameters.Level}][${Parameters.Context}]${Parameters.Meta} ${Parameters.Message}`; +const DEFAULT_PATTERN = `[{timestamp}][{level}][{context}]{meta} {message}`; + +export const patternSchema = schema.string({ + validate: string => { + TimestampConversion.validate!(string); + }, +}); const patternLayoutSchema = schema.object({ highlight: schema.maybe(schema.boolean()), kind: schema.literal('pattern'), - pattern: schema.maybe(schema.string()), + pattern: schema.maybe(patternSchema), }); +const conversions: Conversion[] = [ + ContextConversion, + MessageConversion, + LevelConversion, + MetaConversion, + PidConversion, + TimestampConversion, +]; + /** @internal */ export type PatternLayoutConfigType = TypeOf; @@ -75,19 +66,6 @@ export type PatternLayoutConfigType = TypeOf; */ export class PatternLayout implements Layout { public static configSchema = patternLayoutSchema; - - private static highlightRecord(record: LogRecord, formattedRecord: Map) { - if (LEVEL_COLORS.has(record.level)) { - const color = LEVEL_COLORS.get(record.level)!; - formattedRecord.set(Parameters.Level, color(formattedRecord.get(Parameters.Level)!)); - } - - formattedRecord.set( - Parameters.Context, - chalk.magenta(formattedRecord.get(Parameters.Context)!) - ); - } - constructor(private readonly pattern = DEFAULT_PATTERN, private readonly highlight = false) {} /** @@ -95,30 +73,14 @@ export class PatternLayout implements Layout { * @param record Instance of `LogRecord` to format into string. */ public format(record: LogRecord): string { - // Error stack is much more useful than just the message. - const message = (record.error && record.error.stack) || record.message; - const formattedRecord = new Map([ - [Parameters.Timestamp, record.timestamp.toISOString()], - [Parameters.Level, record.level.id.toUpperCase().padEnd(5)], - [Parameters.Context, record.context], - [Parameters.Message, message], - [Parameters.Pid, String(record.pid)], - ]); - - if (this.highlight) { - PatternLayout.highlightRecord(record, formattedRecord); + let recordString = this.pattern; + for (const conversion of conversions) { + recordString = recordString.replace( + conversion.pattern, + conversion.formatter.bind(null, record, this.highlight) + ); } - return this.pattern.replace(PATTERN_REGEX, match => { - if (match === Parameters.Meta) { - return PatternLayout.formatMeta(record); - } - - return formattedRecord.get(match)!; - }); - } - - private static formatMeta(record: LogRecord): string { - return record.meta ? `[${JSON.stringify(record.meta)}]` : ''; + return recordString; } }