Skip to content

Commit d2ebacf

Browse files
feat(formatter): Add YScope formatter for structured logs and remove Logback-style formatter. (#123)
Co-authored-by: Junhao Liao <[email protected]>
1 parent 7e6073f commit d2ebacf

File tree

11 files changed

+492
-183
lines changed

11 files changed

+492
-183
lines changed

src/components/modals/SettingsModal/SettingsDialog.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ import ThemeSwitchToggle from "./ThemeSwitchToggle";
3333

3434
const CONFIG_FORM_FIELDS = [
3535
{
36-
helperText: "[JSON] Log messages conversion pattern. The current syntax is similar to" +
37-
" Logback conversion patterns but will change in a future release.",
36+
helperText: `[JSON] Log message conversion pattern: use field placeholders to insert
37+
values from JSON log events. The syntax is
38+
\`{<field-name>[:<formatter-name>[:<formatter-options>]]}\`, where \`field-name\` is
39+
required, while \`formatter-name\` and \`formatter-options\` are optional. For example,
40+
the following placeholder would format a timestamp field with name \`@timestamp\`:
41+
\`{@timestamp:timestamp:YYYY-MM-DD HH\\:mm\\:ss.SSS}\`.`,
3842
initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS).formatString,
3943
label: "Decoder: Format string",
4044
name: LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING,

src/services/decoders/ClpIrDecoder.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import {Formatter} from "../../typings/formatters";
1313
import {JsonObject} from "../../typings/js";
1414
import {LogLevelFilter} from "../../typings/logs";
15-
import LogbackFormatter from "../formatters/LogbackFormatter";
15+
import YscopeFormatter from "../formatters/YscopeFormatter";
1616
import {
1717
convertToDayjsTimestamp,
1818
isJsonObject,
@@ -39,7 +39,7 @@ class ClpIrDecoder implements Decoder {
3939
this.#streamType = streamType;
4040
this.#streamReader = streamReader;
4141
this.#formatter = (streamType === CLP_IR_STREAM_TYPE.STRUCTURED) ?
42-
new LogbackFormatter({formatString: decoderOptions.formatString}) :
42+
new YscopeFormatter({formatString: decoderOptions.formatString}) :
4343
null;
4444
}
4545

@@ -87,7 +87,7 @@ class ClpIrDecoder implements Decoder {
8787
}
8888

8989
setFormatterOptions (options: DecoderOptions): boolean {
90-
this.#formatter = new LogbackFormatter({formatString: options.formatString});
90+
this.#formatter = new YscopeFormatter({formatString: options.formatString});
9191

9292
return true;
9393
}

src/services/decoders/JsonlDecoder/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
LogEvent,
1717
LogLevelFilter,
1818
} from "../../../typings/logs";
19-
import LogbackFormatter from "../../formatters/LogbackFormatter";
19+
import YscopeFormatter from "../../formatters/YscopeFormatter";
2020
import {
2121
convertToDayjsTimestamp,
2222
convertToLogLevelValue,
@@ -53,7 +53,7 @@ class JsonlDecoder implements Decoder {
5353
this.#dataArray = dataArray;
5454
this.#logLevelKey = decoderOptions.logLevelKey;
5555
this.#timestampKey = decoderOptions.timestampKey;
56-
this.#formatter = new LogbackFormatter({formatString: decoderOptions.formatString});
56+
this.#formatter = new YscopeFormatter({formatString: decoderOptions.formatString});
5757
}
5858

5959
getEstimatedNumEvents (): number {
@@ -82,7 +82,7 @@ class JsonlDecoder implements Decoder {
8282
}
8383

8484
setFormatterOptions (options: DecoderOptions): boolean {
85-
this.#formatter = new LogbackFormatter({formatString: options.formatString});
85+
this.#formatter = new YscopeFormatter({formatString: options.formatString});
8686

8787
return true;
8888
}

src/services/formatters/LogbackFormatter.ts

Lines changed: 0 additions & 152 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {Nullable} from "../../../../typings/common";
2+
import {YscopeFieldFormatter} from "../../../../typings/formatters";
3+
import {JsonValue} from "../../../../typings/js";
4+
import {jsonValueToString} from "../utils";
5+
6+
7+
/**
8+
* A field formatter that rounds numerical values to the nearest integer.
9+
* For non-numerical values, the field's value is converted to a string then returned as-is.
10+
* Options: None.
11+
*/
12+
class RoundFormatter implements YscopeFieldFormatter {
13+
constructor (options: Nullable<string>) {
14+
if (null !== options) {
15+
throw Error(`RoundFormatter does not support options "${options}"`);
16+
}
17+
}
18+
19+
// eslint-disable-next-line class-methods-use-this
20+
formatField (field: JsonValue): string {
21+
if ("number" === typeof field) {
22+
field = Math.round(field);
23+
}
24+
25+
return jsonValueToString(field);
26+
}
27+
}
28+
29+
export default RoundFormatter;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {Dayjs} from "dayjs";
2+
3+
import {Nullable} from "../../../../typings/common";
4+
import {YscopeFieldFormatter} from "../../../../typings/formatters";
5+
import {JsonValue} from "../../../../typings/js";
6+
import {convertToDayjsTimestamp} from "../../../decoders/JsonlDecoder/utils";
7+
8+
9+
/**
10+
* A formatter for timestamp values, using a specified date-time pattern.
11+
* Options: If no pattern is provided, defaults to ISO 8601 format.
12+
*/
13+
class TimestampFormatter implements YscopeFieldFormatter {
14+
#dateFormat: Nullable<string> = null;
15+
16+
constructor (options: Nullable<string>) {
17+
this.#dateFormat = options;
18+
}
19+
20+
formatField (field: JsonValue): string {
21+
// eslint-disable-next-line no-warning-comments
22+
// TODO: We already parsed the timestamp during deserialization so this is perhaps
23+
// inefficient. However, this field formatter can be used for multiple keys, so using
24+
// the single parsed timestamp by itself would not work. Perhaps in future we can check
25+
// if the key is the same as timestamp key and avoid parsing again.
26+
const timestamp: Dayjs = convertToDayjsTimestamp(field);
27+
if (null === this.#dateFormat) {
28+
return timestamp.format();
29+
}
30+
31+
return timestamp.format(this.#dateFormat);
32+
}
33+
}
34+
35+
export default TimestampFormatter;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {Nullable} from "../../../typings/common";
2+
import {
3+
FIELD_PLACEHOLDER_REGEX,
4+
Formatter,
5+
FormatterOptionsType,
6+
REPLACEMENT_CHARACTER,
7+
YscopeFieldFormatter,
8+
YscopeFieldPlaceholder,
9+
} from "../../../typings/formatters";
10+
import {LogEvent} from "../../../typings/logs";
11+
import {
12+
getFormattedField,
13+
removeEscapeCharacters,
14+
replaceDoubleBacklash,
15+
splitFieldPlaceholder,
16+
YSCOPE_FIELD_FORMATTER_MAP,
17+
} from "./utils";
18+
19+
20+
/**
21+
* A formatter that uses a YScope format string to format log events into a string. See
22+
* `YscopeFormatterOptionsType` for details about the format string.
23+
*/
24+
class YscopeFormatter implements Formatter {
25+
readonly #processedFormatString: string;
26+
27+
#fieldPlaceholders: YscopeFieldPlaceholder[] = [];
28+
29+
constructor (options: FormatterOptionsType) {
30+
if (options.formatString.includes(REPLACEMENT_CHARACTER)) {
31+
console.warn("Unicode replacement character `U+FFFD` is found in Decoder Format" +
32+
' String, which will appear as "\\".');
33+
}
34+
35+
this.#processedFormatString = replaceDoubleBacklash(options.formatString);
36+
this.#parseFieldPlaceholder();
37+
}
38+
39+
formatLogEvent (logEvent: LogEvent): string {
40+
const formattedLogFragments: string[] = [];
41+
let lastIndex = 0;
42+
43+
for (const fieldPlaceholder of this.#fieldPlaceholders) {
44+
const formatStringFragment =
45+
this.#processedFormatString.slice(lastIndex, fieldPlaceholder.range.start);
46+
47+
formattedLogFragments.push(removeEscapeCharacters(formatStringFragment));
48+
formattedLogFragments.push(getFormattedField(logEvent, fieldPlaceholder));
49+
lastIndex = fieldPlaceholder.range.end;
50+
}
51+
52+
const remainder = this.#processedFormatString.slice(lastIndex);
53+
formattedLogFragments.push(removeEscapeCharacters(remainder));
54+
55+
return `${formattedLogFragments.join("")}\n`;
56+
}
57+
58+
/**
59+
* Parses field placeholders in format string. For each field placeholder, creates a
60+
* corresponding `YscopeFieldFormatter` using the placeholder's field name, formatter type,
61+
* and formatter options. Each `YscopeFieldFormatter` is then stored on the
62+
* class-level array `#fieldPlaceholders`.
63+
*
64+
* @throws Error if `FIELD_PLACEHOLDER_REGEX` does not contain a capture group.
65+
* @throws Error if a formatter type is not supported.
66+
*/
67+
#parseFieldPlaceholder () {
68+
const placeholderPattern = new RegExp(FIELD_PLACEHOLDER_REGEX, "g");
69+
const it = this.#processedFormatString.matchAll(placeholderPattern);
70+
for (const match of it) {
71+
// `fullMatch` includes braces and `groupMatch` excludes them.
72+
const [fullMatch, groupMatch]: (string | undefined) [] = match;
73+
74+
if ("undefined" === typeof groupMatch) {
75+
throw Error("Field placeholder regex is invalid and does not have a capture group");
76+
}
77+
78+
const {fieldNameKeys, formatterName, formatterOptions} =
79+
splitFieldPlaceholder(groupMatch);
80+
81+
let fieldFormatter: Nullable<YscopeFieldFormatter> = null;
82+
if (null !== formatterName) {
83+
const FieldFormatterConstructor = YSCOPE_FIELD_FORMATTER_MAP[formatterName];
84+
if ("undefined" === typeof FieldFormatterConstructor) {
85+
throw Error(`Formatter ${formatterName} is not currently supported`);
86+
}
87+
fieldFormatter = new FieldFormatterConstructor(formatterOptions);
88+
}
89+
90+
this.#fieldPlaceholders.push({
91+
fieldNameKeys: fieldNameKeys,
92+
fieldFormatter: fieldFormatter,
93+
range: {
94+
start: match.index,
95+
end: match.index + fullMatch.length,
96+
},
97+
});
98+
}
99+
}
100+
}
101+
102+
export default YscopeFormatter;

0 commit comments

Comments
 (0)