Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(formatter): Add YScope formatter for structured logs and remove Logback-style formatter. #123

Merged
merged 24 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/modals/SettingsModal/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ import ThemeSwitchToggle from "./ThemeSwitchToggle";

const CONFIG_FORM_FIELDS = [
{
helperText: "[JSON] Log messages conversion pattern. The current syntax is similar to" +
" Logback conversion patterns but will change in a future release.",
helperText: `[JSON] Log message conversion pattern: use field placeholders to insert
values from JSON log events. The syntax is
\`{<field-name>[:<formatter-name>[:<formatter-options>]]}\`, where \`field-name\` is
required, while \`formatter-name\` and \`formatter-options\` are optional. For example,
the following placeholder would format a timestamp field with name \`@timestamp\`:
\`{@timestamp:timestamp:YYYY-MM-DD HH\\:mm\\:ss.SSS}\`.`,
initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS).formatString,
label: "Decoder: Format string",
name: LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING,
Expand Down
6 changes: 3 additions & 3 deletions src/services/decoders/ClpIrDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import {Formatter} from "../../typings/formatters";
import {JsonObject} from "../../typings/js";
import {LogLevelFilter} from "../../typings/logs";
import LogbackFormatter from "../formatters/LogbackFormatter";
import YscopeFormatter from "../formatters/YscopeFormatter";
import {
convertToDayjsTimestamp,
isJsonObject,
Expand All @@ -39,7 +39,7 @@ class ClpIrDecoder implements Decoder {
this.#streamType = streamType;
this.#streamReader = streamReader;
this.#formatter = (streamType === CLP_IR_STREAM_TYPE.STRUCTURED) ?
new LogbackFormatter({formatString: decoderOptions.formatString}) :
new YscopeFormatter({formatString: decoderOptions.formatString}) :
junhaoliao marked this conversation as resolved.
Show resolved Hide resolved
null;
}

Expand Down Expand Up @@ -87,7 +87,7 @@ class ClpIrDecoder implements Decoder {
}

setFormatterOptions (options: DecoderOptions): boolean {
this.#formatter = new LogbackFormatter({formatString: options.formatString});
this.#formatter = new YscopeFormatter({formatString: options.formatString});
junhaoliao marked this conversation as resolved.
Show resolved Hide resolved

return true;
}
Expand Down
6 changes: 3 additions & 3 deletions src/services/decoders/JsonlDecoder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
LogEvent,
LogLevelFilter,
} from "../../../typings/logs";
import LogbackFormatter from "../../formatters/LogbackFormatter";
import YscopeFormatter from "../../formatters/YscopeFormatter";
import {
convertToDayjsTimestamp,
convertToLogLevelValue,
Expand Down Expand Up @@ -53,7 +53,7 @@ class JsonlDecoder implements Decoder {
this.#dataArray = dataArray;
this.#logLevelKey = decoderOptions.logLevelKey;
this.#timestampKey = decoderOptions.timestampKey;
this.#formatter = new LogbackFormatter({formatString: decoderOptions.formatString});
this.#formatter = new YscopeFormatter({formatString: decoderOptions.formatString});
}

getEstimatedNumEvents (): number {
Expand Down Expand Up @@ -82,7 +82,7 @@ class JsonlDecoder implements Decoder {
}

setFormatterOptions (options: DecoderOptions): boolean {
this.#formatter = new LogbackFormatter({formatString: options.formatString});
this.#formatter = new YscopeFormatter({formatString: options.formatString});

return true;
}
Expand Down
152 changes: 0 additions & 152 deletions src/services/formatters/LogbackFormatter.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Nullable} from "../../../../typings/common";
import {YscopeFieldFormatter} from "../../../../typings/formatters";
import {JsonValue} from "../../../../typings/js";
import {jsonValueToString} from "../utils";


/**
* A field formatter that rounds numerical values to the nearest integer.
* For non-numerical values, the field's value is converted to a string then returned as-is.
* Options: None.
*/
class RoundFormatter implements YscopeFieldFormatter {
constructor (options: Nullable<string>) {
if (null !== options) {
throw Error(`RoundFormatter does not support options "${options}"`);
}
}

// eslint-disable-next-line class-methods-use-this
formatField (field: JsonValue): string {
if ("number" === typeof field) {
field = Math.round(field);
}

return jsonValueToString(field);
}
}

export default RoundFormatter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {Dayjs} from "dayjs";

import {Nullable} from "../../../../typings/common";
import {YscopeFieldFormatter} from "../../../../typings/formatters";
import {JsonValue} from "../../../../typings/js";
import {convertToDayjsTimestamp} from "../../../decoders/JsonlDecoder/utils";


/**
* A formatter for timestamp values, using a specified date-time pattern.
* Options: If no pattern is provided, defaults to ISO 8601 format.
*/
class TimestampFormatter implements YscopeFieldFormatter {
#dateFormat: Nullable<string> = null;

constructor (options: Nullable<string>) {
this.#dateFormat = options;
}

formatField (field: JsonValue): string {
// eslint-disable-next-line no-warning-comments
// TODO: We already parsed the timestamp during deserialization so this is perhaps
// inefficient. However, this field formatter can be used for multiple keys, so using
// the single parsed timestamp by itself would not work. Perhaps in future we can check
// if the key is the same as timestamp key and avoid parsing again.
const timestamp: Dayjs = convertToDayjsTimestamp(field);
if (null === this.#dateFormat) {
return timestamp.format();
}

return timestamp.format(this.#dateFormat);
}
}

export default TimestampFormatter;
102 changes: 102 additions & 0 deletions src/services/formatters/YscopeFormatter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {Nullable} from "../../../typings/common";
import {
FIELD_PLACEHOLDER_REGEX,
Formatter,
FormatterOptionsType,
REPLACEMENT_CHARACTER,
YscopeFieldFormatter,
YscopeFieldPlaceholder,
} from "../../../typings/formatters";
import {LogEvent} from "../../../typings/logs";
import {
getFormattedField,
removeEscapeCharacters,
replaceDoubleBacklash,
splitFieldPlaceholder,
YSCOPE_FIELD_FORMATTER_MAP,
} from "./utils";


/**
* A formatter that uses a YScope format string to format log events into a string. See
* `YscopeFormatterOptionsType` for details about the format string.
*/
class YscopeFormatter implements Formatter {
readonly #processedFormatString: string;

#fieldPlaceholders: YscopeFieldPlaceholder[] = [];

constructor (options: FormatterOptionsType) {
if (options.formatString.includes(REPLACEMENT_CHARACTER)) {
console.warn("Unicode replacement character `U+FFFD` is found in Decoder Format" +
' String, which will appear as "\\".');
}

this.#processedFormatString = replaceDoubleBacklash(options.formatString);
this.#parseFieldPlaceholder();
}

formatLogEvent (logEvent: LogEvent): string {
const formattedLogFragments: string[] = [];
let lastIndex = 0;

for (const fieldPlaceholder of this.#fieldPlaceholders) {
const formatStringFragment =
this.#processedFormatString.slice(lastIndex, fieldPlaceholder.range.start);

formattedLogFragments.push(removeEscapeCharacters(formatStringFragment));
formattedLogFragments.push(getFormattedField(logEvent, fieldPlaceholder));
lastIndex = fieldPlaceholder.range.end;
}

const remainder = this.#processedFormatString.slice(lastIndex);
formattedLogFragments.push(removeEscapeCharacters(remainder));

return `${formattedLogFragments.join("")}\n`;
}

/**
* Parses field placeholders in format string. For each field placeholder, creates a
* corresponding `YscopeFieldFormatter` using the placeholder's field name, formatter type,
* and formatter options. Each `YscopeFieldFormatter` is then stored on the
* class-level array `#fieldPlaceholders`.
*
* @throws Error if `FIELD_PLACEHOLDER_REGEX` does not contain a capture group.
* @throws Error if a formatter type is not supported.
*/
#parseFieldPlaceholder () {
const placeholderPattern = new RegExp(FIELD_PLACEHOLDER_REGEX, "g");
const it = this.#processedFormatString.matchAll(placeholderPattern);
for (const match of it) {
// `fullMatch` includes braces and `groupMatch` excludes them.
const [fullMatch, groupMatch]: (string | undefined) [] = match;

if ("undefined" === typeof groupMatch) {
throw Error("Field placeholder regex is invalid and does not have a capture group");
}

const {fieldNameKeys, formatterName, formatterOptions} =
splitFieldPlaceholder(groupMatch);

let fieldFormatter: Nullable<YscopeFieldFormatter> = null;
if (null !== formatterName) {
const FieldFormatterConstructor = YSCOPE_FIELD_FORMATTER_MAP[formatterName];
if ("undefined" === typeof FieldFormatterConstructor) {
throw Error(`Formatter ${formatterName} is not currently supported`);
}
fieldFormatter = new FieldFormatterConstructor(formatterOptions);
Comment on lines +83 to +87
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle unsupported formatter types more gracefully

When an unsupported formatter is encountered, the code throws an error:

if ("undefined" === typeof FieldFormatterConstructor) {
    throw Error(`Formatter ${formatterName} is not currently supported`);
}

This could cause the entire application to crash if the format string includes an unknown formatter. Consider handling this scenario more gracefully by notifying the user about the unsupported formatter without throwing an uncaught exception. Possible approaches include:

  • Logging a warning and skipping the unsupported formatter.
  • Providing a default formatter as a fallback.
  • Returning the placeholder as-is in the formatted output.

}

this.#fieldPlaceholders.push({
fieldNameKeys: fieldNameKeys,
fieldFormatter: fieldFormatter,
range: {
start: match.index,
end: match.index + fullMatch.length,
},
});
}
}
}

export default YscopeFormatter;
Loading
Loading