diff --git a/pom.xml b/pom.xml index 6413240e..896a8920 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,8 @@ + 1.0 + 1.0.4 1.5.2.Final 1.2.0.Beta10 4.12 @@ -70,6 +72,24 @@ wildfly-common ${version.org.wildfly.common.wildfly-common} + + + javax.json + javax.json-api + ${version.javax.json} + + true + + + + org.glassfish + javax.json + ${version.org.glassfish.javax.json} + + true + + + junit junit diff --git a/src/main/java/org/jboss/logmanager/formatters/IndentingXmlWriter.java b/src/main/java/org/jboss/logmanager/formatters/IndentingXmlWriter.java new file mode 100644 index 00000000..e5eb438c --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/IndentingXmlWriter.java @@ -0,0 +1,329 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import javax.xml.namespace.NamespaceContext; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +/** + * An XML stream writer which pretty prints the XML. + * + * @author James R. Perkins + */ +class IndentingXmlWriter implements XMLStreamWriter, XMLStreamConstants { + + private static final String SPACES = " "; + + private final XMLStreamWriter delegate; + private int index; + private int state = START_DOCUMENT; + private boolean indentEnd; + + IndentingXmlWriter(final XMLStreamWriter delegate) { + this.delegate = delegate; + index = 0; + indentEnd = false; + } + + private void indent() throws XMLStreamException { + final int index = this.index; + if (index > 0) { + for (int i = 0; i < index; i++) { + delegate.writeCharacters(SPACES); + } + } + + } + + private void newline() throws XMLStreamException { + delegate.writeCharacters("\n"); + } + + @Override + public void writeStartElement(final String localName) throws XMLStreamException { + newline(); + indent(); + delegate.writeStartElement(localName); + indentEnd = false; + state = START_ELEMENT; + index++; + } + + @Override + public void writeStartElement(final String namespaceURI, final String localName) throws XMLStreamException { + newline(); + indent(); + delegate.writeStartElement(namespaceURI, localName); + indentEnd = false; + state = START_ELEMENT; + index++; + } + + @Override + public void writeStartElement(final String prefix, final String localName, final String namespaceURI) throws XMLStreamException { + newline(); + indent(); + delegate.writeStartElement(prefix, localName, namespaceURI); + indentEnd = false; + state = START_ELEMENT; + index++; + } + + @Override + public void writeEmptyElement(final String namespaceURI, final String localName) throws XMLStreamException { + newline(); + indent(); + delegate.writeEmptyElement(namespaceURI, localName); + state = END_ELEMENT; + } + + @Override + public void writeEmptyElement(final String prefix, final String localName, final String namespaceURI) throws XMLStreamException { + newline(); + indent(); + delegate.writeEmptyElement(prefix, localName, namespaceURI); + state = END_ELEMENT; + } + + @Override + public void writeEmptyElement(final String localName) throws XMLStreamException { + newline(); + indent(); + delegate.writeEmptyElement(localName); + state = END_ELEMENT; + } + + @Override + public void writeEndElement() throws XMLStreamException { + index--; + if (state != CHARACTERS || indentEnd) { + newline(); + indent(); + indentEnd = false; + } + delegate.writeEndElement(); + state = END_ELEMENT; + } + + @Override + public void writeEndDocument() throws XMLStreamException { + delegate.writeEndDocument(); + state = END_DOCUMENT; + } + + @Override + public void close() throws XMLStreamException { + delegate.close(); + } + + @Override + public void flush() throws XMLStreamException { + delegate.flush(); + } + + @Override + public void writeAttribute(final String localName, final String value) throws XMLStreamException { + delegate.writeAttribute(localName, value); + } + + @Override + public void writeAttribute(final String prefix, final String namespaceURI, final String localName, final String value) throws XMLStreamException { + delegate.writeAttribute(prefix, namespaceURI, localName, value); + } + + @Override + public void writeAttribute(final String namespaceURI, final String localName, final String value) throws XMLStreamException { + delegate.writeAttribute(namespaceURI, localName, value); + } + + @Override + public void writeNamespace(final String prefix, final String namespaceURI) throws XMLStreamException { + delegate.writeNamespace(prefix, namespaceURI); + } + + @Override + public void writeDefaultNamespace(final String namespaceURI) throws XMLStreamException { + delegate.writeDefaultNamespace(namespaceURI); + } + + @Override + public void writeComment(final String data) throws XMLStreamException { + newline(); + indent(); + delegate.writeComment(data); + state = COMMENT; + } + + @Override + public void writeProcessingInstruction(final String target) throws XMLStreamException { + newline(); + indent(); + delegate.writeProcessingInstruction(target); + state = PROCESSING_INSTRUCTION; + } + + @Override + public void writeProcessingInstruction(final String target, final String data) throws XMLStreamException { + newline(); + indent(); + delegate.writeProcessingInstruction(target, data); + state = PROCESSING_INSTRUCTION; + } + + @Override + public void writeCData(final String data) throws XMLStreamException { + delegate.writeCData(data); + state = CDATA; + } + + @Override + public void writeDTD(final String dtd) throws XMLStreamException { + newline(); + indent(); + delegate.writeDTD(dtd); + state = DTD; + } + + @Override + public void writeEntityRef(final String name) throws XMLStreamException { + delegate.writeEntityRef(name); + state = ENTITY_REFERENCE; + } + + @Override + public void writeStartDocument() throws XMLStreamException { + delegate.writeStartDocument(); + newline(); + state = START_DOCUMENT; + } + + @Override + public void writeStartDocument(final String version) throws XMLStreamException { + delegate.writeStartDocument(version); + newline(); + state = START_DOCUMENT; + } + + @Override + public void writeStartDocument(final String encoding, final String version) throws XMLStreamException { + delegate.writeStartDocument(encoding, version); + newline(); + state = START_DOCUMENT; + } + + @Override + public void writeCharacters(final String text) throws XMLStreamException { + indentEnd = false; + boolean first = true; + final Iterator iterator = new SplitIterator(text, '\n'); + while (iterator.hasNext()) { + final String t = iterator.next(); + // On first iteration if more than one line is required, skip to a new line and indent + if (first && iterator.hasNext()) { + first = false; + newline(); + indent(); + } + delegate.writeCharacters(t); + if (iterator.hasNext()) { + newline(); + indent(); + indentEnd = true; + } + } + state = CHARACTERS; + } + + @Override + public void writeCharacters(final char[] text, final int start, final int len) throws XMLStreamException { + delegate.writeCharacters(text, start, len); + } + + @Override + public String getPrefix(final String uri) throws XMLStreamException { + return delegate.getPrefix(uri); + } + + @Override + public void setPrefix(final String prefix, final String uri) throws XMLStreamException { + delegate.setPrefix(prefix, uri); + } + + @Override + public void setDefaultNamespace(final String uri) throws XMLStreamException { + delegate.setDefaultNamespace(uri); + } + + @Override + public void setNamespaceContext(final NamespaceContext context) throws XMLStreamException { + delegate.setNamespaceContext(context); + } + + @Override + public NamespaceContext getNamespaceContext() { + return delegate.getNamespaceContext(); + } + + @Override + public Object getProperty(final String name) throws IllegalArgumentException { + return delegate.getProperty(name); + } + + private static class SplitIterator implements Iterator { + + private final String value; + private final char delimiter; + private int index; + + private SplitIterator(final String value, final char delimiter) { + this.value = value; + this.delimiter = delimiter; + index = 0; + } + + @Override + public boolean hasNext() { + return index != -1; + } + + @Override + public String next() { + final int index = this.index; + if (index == -1) { + throw new NoSuchElementException(); + } + int x = value.indexOf(delimiter, index); + try { + return x == -1 ? value.substring(index) : value.substring(index, x); + } finally { + this.index = (x == -1 ? -1 : x + 1); + } + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/main/java/org/jboss/logmanager/formatters/JsonFormatter.java b/src/main/java/org/jboss/logmanager/formatters/JsonFormatter.java new file mode 100644 index 00000000..5a6c9eae --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/JsonFormatter.java @@ -0,0 +1,269 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.io.Writer; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.json.Json; +import javax.json.JsonValue; +import javax.json.stream.JsonGenerator; +import javax.json.stream.JsonGeneratorFactory; + +/** + * A formatter that outputs the record into JSON format optionally printing details. + *

+ * Note that including details can be expensive in terms of calculating the caller. + *

+ *

The details include;

+ *
    + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceClassName() source class name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceFileName() source file name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceMethodName() source method name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceLineNumber() source line number}
  • + *
+ * + * @author James R. Perkins + */ +@SuppressWarnings({"unused", "WeakerAccess"}) +public class JsonFormatter extends StructuredFormatter { + + private final Map config; + + private JsonGeneratorFactory factory; + + /** + * Creates a new JSON formatter. + */ + public JsonFormatter() { + this(Collections.emptyMap()); + } + + /** + * Creates a new JSON formatter. + * + * @param keyOverrides a map of overrides for the default keys + */ + public JsonFormatter(final Map keyOverrides) { + super(keyOverrides); + config = new HashMap<>(); + factory = Json.createGeneratorFactory(config); + } + + /** + * Indicates whether or not pretty printing is enabled. + * + * @return {@code true} if pretty printing is enabled, otherwise {@code false} + */ + public boolean isPrettyPrint() { + synchronized (config) { + return (config.containsKey(javax.json.stream.JsonGenerator.PRETTY_PRINTING) ? (Boolean) config.get(javax.json.stream.JsonGenerator.PRETTY_PRINTING) : false); + } + } + + /** + * Turns on or off pretty printing. + * + * @param prettyPrint {@code true} to turn on pretty printing or {@code false} to turn it off + */ + public void setPrettyPrint(final boolean prettyPrint) { + synchronized (config) { + if (prettyPrint) { + config.put(JsonGenerator.PRETTY_PRINTING, true); + } else { + config.remove(JsonGenerator.PRETTY_PRINTING); + } + factory = Json.createGeneratorFactory(config); + } + } + + @Override + protected Generator createGenerator(final Writer writer) { + final JsonGeneratorFactory factory; + synchronized (config) { + factory = this.factory; + } + return new FormatterJsonGenerator(factory.createGenerator(writer)); + } + + private class FormatterJsonGenerator implements Generator { + private final JsonGenerator generator; + + private FormatterJsonGenerator(final JsonGenerator generator) { + this.generator = generator; + } + + @Override + public Generator begin() { + generator.writeStartObject(); + return this; + } + + @Override + public Generator add(final String key, final int value) { + generator.write(key, value); + return this; + } + + @Override + public Generator add(final String key, final long value) { + generator.write(key, value); + return this; + } + + @Override + public Generator add(final String key, final Map value) { + generator.writeStartObject(key); + if (value != null) { + for (Map.Entry entry : value.entrySet()) { + writeObject(entry.getKey(), entry.getValue()); + } + } + generator.writeEnd(); + return this; + } + + @Override + public Generator add(final String key, final String value) { + if (value == null) { + generator.writeNull(key); + } else { + generator.write(key, value); + } + return this; + } + + @Override + public Generator startObject(final String key) throws Exception { + if (key == null) { + generator.writeStartObject(); + } else { + generator.writeStartObject(key); + } + return this; + } + + @Override + public Generator endObject() throws Exception { + generator.writeEnd(); + return this; + } + + @Override + public Generator startArray(final String key) throws Exception { + if (key == null) { + generator.writeStartArray(); + } else { + generator.writeStartArray(key); + } + return this; + } + + @Override + public Generator endArray() throws Exception { + generator.writeEnd(); + return this; + } + + @Override + public Generator end() { + generator.writeEnd(); // end record + generator.flush(); + generator.close(); + return this; + } + + private void writeObject(final String key, final Object obj) { + if (obj == null) { + if (key == null) { + generator.writeNull(); + } else { + generator.writeNull(key); + } + } else if (obj instanceof Boolean) { + final Boolean value = (Boolean) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof Integer) { + final Integer value = (Integer) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof Long) { + final Long value = (Long) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof Double) { + final Double value = (Double) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof BigInteger) { + final BigInteger value = (BigInteger) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof BigDecimal) { + final BigDecimal value = (BigDecimal) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof String) { + final String value = (String) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else if (obj instanceof JsonValue) { + final JsonValue value = (JsonValue) obj; + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } else { + final String value = String.valueOf(obj); + if (key == null) { + generator.write(value); + } else { + generator.write(key, value); + } + } + } + } +} diff --git a/src/main/java/org/jboss/logmanager/formatters/LogstashFormatter.java b/src/main/java/org/jboss/logmanager/formatters/LogstashFormatter.java new file mode 100644 index 00000000..01fc47ab --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/LogstashFormatter.java @@ -0,0 +1,79 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.util.Collections; +import java.util.Map; + +import org.jboss.logmanager.ExtLogRecord; + +/** + * A {@link JsonFormatter JSON formatter} which adds the {@code @version} to the generated JSON and overrides the + * {@code timestamp} key to {@code @timestamp}. + *

+ * The default {@link #getVersion() version} is {@code 1}. + *

+ * + * @author James R. Perkins + */ +@SuppressWarnings("WeakerAccess") +public class LogstashFormatter extends JsonFormatter { + + private volatile int version = 1; + + /** + * Create the lostash formatter. + */ + public LogstashFormatter() { + this(Collections.singletonMap(Key.TIMESTAMP, "@timestamp")); + } + + /** + * Create the logstash formatter overriding any default keys + * + * @param keyOverrides the keys used to override the defaults + */ + public LogstashFormatter(final Map keyOverrides) { + super(keyOverrides); + } + + @Override + protected void before(final Generator generator, final ExtLogRecord record) throws Exception { + generator.add("@version", version); + } + + /** + * Returns the version being used for the {@code @version} property. + * + * @return the version being used + */ + public int getVersion() { + return version; + } + + /** + * Sets the version to use for the {@code @version} property. + * + * @param version the version to use + */ + public void setVersion(final int version) { + this.version = version; + } +} diff --git a/src/main/java/org/jboss/logmanager/formatters/StringBuilderWriter.java b/src/main/java/org/jboss/logmanager/formatters/StringBuilderWriter.java index 70000cc0..c1d8b799 100644 --- a/src/main/java/org/jboss/logmanager/formatters/StringBuilderWriter.java +++ b/src/main/java/org/jboss/logmanager/formatters/StringBuilderWriter.java @@ -20,39 +20,81 @@ package org.jboss.logmanager.formatters; import java.io.Writer; -import java.io.IOException; final class StringBuilderWriter extends Writer { private final StringBuilder builder; + StringBuilderWriter() { + this(new StringBuilder()); + } + public StringBuilderWriter(final StringBuilder builder) { this.builder = builder; } + /** + * Clears the builder used for the writer. + * + * @see StringBuilder#setLength(int) + */ + void clear() { + builder.setLength(0); + } + + @Override public void write(final char[] cbuf, final int off, final int len) { builder.append(cbuf, off, len); } + @Override public void write(final int c) { - builder.append(c); + builder.append((char) c); } + @Override public void write(final char[] cbuf) { builder.append(cbuf); } - public void write(final String str) throws IOException { + @Override + public void write(final String str) { builder.append(str); } - public void write(final String str, final int off, final int len) throws IOException { + @Override + public void write(final String str, final int off, final int len) { builder.append(str, off, len); } - public void flush() throws IOException { + @Override + public Writer append(final CharSequence csq) { + builder.append(csq); + return this; + } + + @Override + public Writer append(final CharSequence csq, final int start, final int end) { + builder.append(csq, start, end); + return this; + } + + @Override + public Writer append(final char c) { + builder.append(c); + return this; + } + + @Override + public void flush() { + } + + @Override + public void close() { } - public void close() throws IOException { + @Override + public String toString() { + return builder.toString(); } } diff --git a/src/main/java/org/jboss/logmanager/formatters/StructuredFormatter.java b/src/main/java/org/jboss/logmanager/formatters/StructuredFormatter.java new file mode 100644 index 00000000..9190e7a7 --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/StructuredFormatter.java @@ -0,0 +1,703 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.io.PrintWriter; +import java.io.Writer; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +import org.jboss.logmanager.ExtFormatter; +import org.jboss.logmanager.ExtLogRecord; + +/** + * An abstract class that uses a generator to help generate structured data from a {@link + * org.jboss.logmanager.ExtLogRecord record}. + *

+ * Note that including details can be expensive in terms of calculating the caller. + *

+ *

+ * By default the {@linkplain #setEndOfRecordDelimiter(String) record delimiter} is set to {@code \n}. + *

+ * + * @author James R. Perkins + */ +@SuppressWarnings({"unused", "WeakerAccess"}) +public abstract class StructuredFormatter extends ExtFormatter { + + /** + * The key used for the structured log record data. + */ + public enum Key { + EXCEPTION("exception"), + EXCEPTION_CAUSED_BY("causedBy"), + EXCEPTION_CIRCULAR_REFERENCE("circularReference"), + EXCEPTION_TYPE("exceptionType"), + EXCEPTION_FRAME("frame"), + EXCEPTION_FRAME_CLASS("class"), + EXCEPTION_FRAME_LINE("line"), + EXCEPTION_FRAME_METHOD("method"), + EXCEPTION_FRAMES("frames"), + EXCEPTION_MESSAGE("message"), + EXCEPTION_REFERENCE_ID("refId"), + EXCEPTION_SUPPRESSED("suppressed"), + HOST_NAME("hostName"), + LEVEL("level"), + LOGGER_CLASS_NAME("loggerClassName"), + LOGGER_NAME("loggerName"), + MDC("mdc"), + MESSAGE("message"), + NDC("ndc"), + PROCESS_ID("processId"), + PROCESS_NAME("processName"), + RECORD("record"), + SEQUENCE("sequence"), + SOURCE_CLASS_NAME("sourceClassName"), + SOURCE_FILE_NAME("sourceFileName"), + SOURCE_LINE_NUMBER("sourceLineNumber"), + SOURCE_METHOD_NAME("sourceMethodName"), + STACK_TRACE("stackTrace"), + THREAD_ID("threadId"), + THREAD_NAME("threadName"), + TIMESTAMP("timestamp"); + + private final String key; + + Key(final String key) { + this.key = key; + } + + /** + * Returns the name of the key for the structure. + * + * @return the name of they key + */ + public String getKey() { + return key; + } + } + + /** + * Defines the way a cause will be formatted. + */ + public enum ExceptionOutputType { + /** + * The cause, if present, will be an array of stack trace elements. This will include suppressed exceptions and + * the {@linkplain Throwable#getCause() cause} of the exception. + */ + DETAILED, + /** + * The cause, if present, will be a string representation of the stack trace in a {@code stackTrace} property. + * The property value is a string created by {@link Throwable#printStackTrace()}. + */ + FORMATTED, + /** + * Adds both the {@link #DETAILED} and {@link #FORMATTED} + */ + DETAILED_AND_FORMATTED + } + + private final Map keyOverrides; + // Guarded by this + private String metaData; + // Guarded by this + private Map metaDataMap; + private volatile boolean printDetails; + private volatile String eorDelimiter = "\n"; + // Guarded by this + private DateTimeFormatter dateTimeFormatter; + // Guarded by this + private ZoneId zoneId; + private volatile ExceptionOutputType exceptionOutputType; + private final StringBuilderWriter writer = new StringBuilderWriter(); + // Guarded by this + private int refId; + + protected StructuredFormatter() { + this(null); + } + + protected StructuredFormatter(final Map keyOverrides) { + this.printDetails = false; + zoneId = ZoneId.systemDefault(); + dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(zoneId); + this.keyOverrides = (keyOverrides == null ? Collections.emptyMap() : new HashMap<>(keyOverrides)); + exceptionOutputType = ExceptionOutputType.DETAILED; + } + + /** + * Creates the generator used to create the structured data. + * + * @return the generator to use + * + * @throws Exception if an error occurs creating the generator + */ + protected abstract Generator createGenerator(Writer writer) throws Exception; + + /** + * Invoked before the structured data is added to the generator. + * + * @param generator the generator to use + * @param record the log record + */ + protected void before(final Generator generator, final ExtLogRecord record) throws Exception { + // do nothing + } + + /** + * Invoked after the structured data has been added to the generator. + * + * @param generator the generator to use + * @param record the log record + */ + protected void after(final Generator generator, final ExtLogRecord record) throws Exception { + // do nothing + } + + /** + * Checks to see if the key should be overridden. + * + * @param defaultKey the default key + * + * @return the overridden key or the default key if no override exists + */ + protected final String getKey(final Key defaultKey) { + if (keyOverrides.containsKey(defaultKey)) { + return keyOverrides.get(defaultKey); + } + return defaultKey.getKey(); + } + + + @Override + public final synchronized String format(final ExtLogRecord record) { + final boolean details = printDetails; + try { + final Generator generator = createGenerator(writer).begin(); + before(generator, record); + + // Add the default structure + generator.add(getKey(Key.TIMESTAMP), dateTimeFormatter.format(Instant.ofEpochMilli(record.getMillis()))) + .add(getKey(Key.SEQUENCE), record.getSequenceNumber()) + .add(getKey(Key.LOGGER_CLASS_NAME), record.getLoggerClassName()) + .add(getKey(Key.LOGGER_NAME), record.getLoggerName()) + .add(getKey(Key.LEVEL), record.getLevel().getName()) + .add(getKey(Key.MESSAGE), record.getFormattedMessage()) + .add(getKey(Key.THREAD_NAME), record.getThreadName()) + .add(getKey(Key.THREAD_ID), record.getThreadID()) + .add(getKey(Key.MDC), record.getMdcCopy()) + .add(getKey(Key.NDC), record.getNdc()); + + if (isNotNullOrEmpty(record.getHostName())) { + generator.add(getKey(Key.HOST_NAME), record.getHostName()); + } + + if (isNotNullOrEmpty(record.getProcessName())) { + generator.add(getKey(Key.PROCESS_NAME), record.getProcessName()); + } + final long processId = record.getProcessId(); + if (processId >= 0) { + generator.add(getKey(Key.PROCESS_ID), record.getProcessId()); + } + + // Add the cause of the log message if applicable + final Throwable thrown = record.getThrown(); + if (thrown != null) { + if (isDetailedExceptionOutputType()) { + refId = 0; + final Map seen = new IdentityHashMap<>(); + generator.startObject(getKey(Key.EXCEPTION)); + addException(generator, thrown, seen); + generator.endObject(); + } + + if (isFormattedExceptionOutputType()) { + final StringBuilderWriter w = new StringBuilderWriter(); + thrown.printStackTrace(new PrintWriter(w)); + generator.add(getKey(Key.STACK_TRACE), w.toString()); + } + } + + // Print any user meta-data + if (isNotNullOrEmpty(metaData)) { + for (String key : metaDataMap.keySet()) { + generator.add(key, metaDataMap.get(key)); + } + } + if (details) { + generator.add(getKey(Key.SOURCE_CLASS_NAME), record.getSourceClassName()) + .add(getKey(Key.SOURCE_FILE_NAME), record.getSourceFileName()) + .add(getKey(Key.SOURCE_METHOD_NAME), record.getSourceMethodName()) + .add(getKey(Key.SOURCE_LINE_NUMBER), record.getSourceLineNumber()); + } + + after(generator, record); + generator.end(); + + // Append an EOL character if desired + if (getEndOfRecordDelimiter() != null) { + writer.append(getEndOfRecordDelimiter()); + } + return writer.toString(); + } catch (Exception e) { + // Wrap and rethrow + throw new RuntimeException(e); + } finally { + // Clear the writer for the next format + writer.clear(); + } + } + + /** + * Returns the character used to indicate the record has is complete. This defaults to {@code \n} and may be + * {@code null} if no end of record character is desired. + * + * @return the end of record delimiter or {@code null} if no delimiter is desired + */ + public String getEndOfRecordDelimiter() { + return eorDelimiter; + } + + /** + * Sets the value to be used to indicate the end of a record. If set to {@code null} no delimiter will be used at + * the end of the record. + * + * @param eorDelimiter the delimiter to be used or {@code null} to not use a delimiter + */ + public void setEndOfRecordDelimiter(final String eorDelimiter) { + this.eorDelimiter = eorDelimiter; + } + + /** + * Returns the value set for meta data. + *

+ * The value is a string where key/value pairs are separated by commas. The key and value are separated by an + * equal sign. + *

+ * + * @return the meta data string or {@code null} if one was not set + * + * @see ValueParser#stringToMap(String) + */ + public String getMetaData() { + return metaData; + } + + /** + * Sets the meta data to use in the structured format. + *

+ * The value is a string where key/value pairs are separated by commas. The key and value are separated by an + * equal sign. + *

+ * + * @param metaData the meta data to set or {@code null} to not format any meta data + * + * @see ValueParser#stringToMap(String) + */ + public synchronized void setMetaData(final String metaData) { + this.metaData = metaData; + metaDataMap = ValueParser.stringToMap(metaData); + } + + /** + * Returns the current formatter used to format a records date and time. + * + * @return the current formatter + */ + public synchronized DateTimeFormatter getDateTimeFormatter() { + return dateTimeFormatter; + } + + /** + * Sets the pattern to use when formatting the date. The pattern must be a valid + * {@link java.time.format.DateTimeFormatter#ofPattern(String)} pattern. + *

+ * If the pattern is {@code null} a default {@linkplain DateTimeFormatter#ISO_OFFSET_DATE_TIME formatter} will be + * used. The {@linkplain #setZoneId(String) zone id} will always be appended to the formatter. By default the zone + * id will default to the {@linkplain ZoneId#systemDefault() systems zone id}. + *

+ * + * @param pattern the pattern to use or {@code null} to use a default pattern + */ + public synchronized void setDateFormat(final String pattern) { + if (pattern == null) { + dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(zoneId); + } else { + dateTimeFormatter = DateTimeFormatter.ofPattern(pattern).withZone(zoneId); + } + } + + /** + * Returns the current zone id used for the {@linkplain #getDateTimeFormatter() date and time formatter}. + * + * @return the current zone id + */ + public synchronized ZoneId getZoneId() { + return zoneId; + } + + /** + * Sets the {@link ZoneId} to use when formatting the date and time from the {@link java.util.logging.LogRecord}. + *

+ * The rules of the id must conform to the rules specified on {@link ZoneId#of(String)}. + *

+ * + * @param zoneId the zone id or {@code null} to use the {@linkplain ZoneId#systemDefault() system default} + * + * @see ZoneId#of(String) + */ + public void setZoneId(final String zoneId) { + final ZoneId changed; + if (zoneId == null) { + changed = ZoneId.systemDefault(); + } else { + changed = ZoneId.of(zoneId); + } + synchronized (this) { + this.zoneId = changed; + dateTimeFormatter = dateTimeFormatter.withZone(changed); + } + } + + /** + * Indicates whether or not details should be printed. + * + * @return {@code true} if details should be printed, otherwise {@code false} + */ + public boolean isPrintDetails() { + return printDetails; + } + + /** + * Sets whether or not details should be printed. + *

+ * Printing the details can be expensive as the values are retrieved from the caller. The details include the + * source class name, source file name, source method name and source line number. + *

+ * + * @param printDetails {@code true} if details should be printed + */ + public void setPrintDetails(@SuppressWarnings("SameParameterValue") final boolean printDetails) { + this.printDetails = printDetails; + } + + /** + * Get the current output type for exceptions. + * + * @return the output type for exceptions + */ + public ExceptionOutputType getExceptionOutputType() { + return exceptionOutputType; + } + + /** + * Set the output type for exceptions. The default is {@link ExceptionOutputType#DETAILED DETAILED}. + * + * @param exceptionOutputType the desired output type, if {@code null} {@link ExceptionOutputType#DETAILED} is used + */ + public void setExceptionOutputType(final ExceptionOutputType exceptionOutputType) { + if (exceptionOutputType == null) { + this.exceptionOutputType = ExceptionOutputType.DETAILED; + } else { + this.exceptionOutputType = exceptionOutputType; + } + } + + /** + * Checks the exception output type and determines if detailed output should be written. + * + * @return {@code true} if detailed output should be written, otherwise {@code false} + */ + protected boolean isDetailedExceptionOutputType() { + final ExceptionOutputType exceptionOutputType = this.exceptionOutputType; + return exceptionOutputType == ExceptionOutputType.DETAILED || + exceptionOutputType == ExceptionOutputType.DETAILED_AND_FORMATTED; + } + + /** + * Checks the exception output type and determines if formatted output should be written. The formatted output is + * equivalent to {@link Throwable#printStackTrace()}. + * + * @return {@code true} if formatted exception output should be written, otherwise {@code false} + */ + protected boolean isFormattedExceptionOutputType() { + final ExceptionOutputType exceptionOutputType = this.exceptionOutputType; + return exceptionOutputType == ExceptionOutputType.FORMATTED || + exceptionOutputType == ExceptionOutputType.DETAILED_AND_FORMATTED; + } + + private void addException(final Generator generator, final Throwable throwable, final Map seen) throws Exception { + if (throwable == null) { + return; + } + if (seen.containsKey(throwable)) { + generator.addAttribute(getKey(Key.EXCEPTION_REFERENCE_ID), seen.get(throwable)); + generator.startObject(getKey(Key.EXCEPTION_CIRCULAR_REFERENCE)); + generator.add(getKey(Key.EXCEPTION_MESSAGE), throwable.getMessage()); + generator.endObject(); // end circular reference + } else { + final int id = ++refId; + seen.put(throwable, id); + generator.addAttribute(getKey(Key.EXCEPTION_REFERENCE_ID), id); + generator.add(getKey(Key.EXCEPTION_TYPE), throwable.getClass().getName()); + generator.add(getKey(Key.EXCEPTION_MESSAGE), throwable.getMessage()); + + final StackTraceElement[] elements = throwable.getStackTrace(); + addStackTraceElements(generator, elements); + + // Render the suppressed messages + final Throwable[] suppressed = throwable.getSuppressed(); + if (suppressed != null && suppressed.length > 0) { + generator.startArray(getKey(Key.EXCEPTION_SUPPRESSED)); + for (Throwable s : suppressed) { + if (generator.wrapArrays()) { + generator.startObject(getKey(Key.EXCEPTION)); + } else { + generator.startObject(null); + } + addException(generator, s, seen); + generator.endObject(); // end exception + } + generator.endArray(); + } + + // Render the cause + final Throwable cause = throwable.getCause(); + if (cause != null) { + generator.startObject(getKey(Key.EXCEPTION_CAUSED_BY)); + generator.startObject(getKey(Key.EXCEPTION)); + addException(generator, cause, seen); + generator.endObject(); + generator.endObject(); // end exception + } + } + } + + private void addStackTraceElements(final Generator generator, final StackTraceElement[] elements) throws Exception { + generator.startArray(getKey(Key.EXCEPTION_FRAMES)); + for (StackTraceElement e : elements) { + if (generator.wrapArrays()) { + generator.startObject(getKey(Key.EXCEPTION_FRAME)); + } else { + generator.startObject(null); + } + generator.add(getKey(Key.EXCEPTION_FRAME_CLASS), e.getClassName()); + generator.add(getKey(Key.EXCEPTION_FRAME_METHOD), e.getMethodName()); + final int line = e.getLineNumber(); + if (line >= 0) { + generator.add(getKey(Key.EXCEPTION_FRAME_LINE), e.getLineNumber()); + } + generator.endObject(); // end exception object + } + generator.endArray(); // end array + } + + private static boolean isNotNullOrEmpty(final String value) { + return value != null && !value.isEmpty(); + } + + private static boolean isNotNullOrEmpty(final Collection value) { + return value != null && !value.isEmpty(); + } + + /** + * A generator used to create the structured output. + */ + @SuppressWarnings("UnusedReturnValue") + protected interface Generator { + + /** + * Initial method invoked at the start of the generation. + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator begin() throws Exception { + return this; + } + + /** + * Writes an integer value. + * + * @param key they key + * @param value the value + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator add(final String key, final int value) throws Exception { + add(key, Integer.toString(value)); + return this; + } + + /** + * Writes a long value. + * + * @param key they key + * @param value the value + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator add(final String key, final long value) throws Exception { + add(key, Long.toString(value)); + return this; + } + + /** + * Writes a map value + * + * @param key the key for the map + * @param value the map + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + Generator add(String key, Map value) throws Exception; + + /** + * Writes a string value. + * + * @param key the key for the value + * @param value the string value + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + Generator add(String key, String value) throws Exception; + + /** + * Writes the start of an object. + *

+ * If the {@link #wrapArrays()} returns {@code false} the key may be {@code null} and implementations should + * handle this. + *

+ * + * @param key they key for the object, or {@code null} if this object was + * {@linkplain #startArray(String) started in an array} and the {@link #wrapArrays()} is + * {@code false} + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + Generator startObject(String key) throws Exception; + + /** + * Writes an end to the object. + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + Generator endObject() throws Exception; + + /** + * Writes the start of an array. This defaults to {@link #startObject(String)} for convenience of generators + * that don't have a specific type for arrays. + * + * @param key they key for the array + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator startArray(String key) throws Exception { + return startObject(key); + } + + /** + * Writes an end for an array. This defaults to {@link #endObject()} for convenience of generators that don't + * have a specific type for arrays. + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator endArray() throws Exception { + return endObject(); + } + + /** + * Writes an attribute. + *

+ * By default this uses the {@link #add(String, int)} method to add the attribute. If a formatter requires + * special handling for attributes, for example an attribute on an XML element, this method can be overridden. + *

+ * + * @param name the name of the attribute + * @param value the value of the attribute + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator addAttribute(final String name, final int value) throws Exception { + return add(name, value); + } + + /** + * Writes an attribute. + *

+ * By default this uses the {@link #add(String, String)} method to add the attribute. If a formatter requires + * special handling for attributes, for example an attribute on an XML element, this method can be overridden. + *

+ * + * @param name the name of the attribute + * @param value the value of the attribute + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data + */ + default Generator addAttribute(final String name, final String value) throws Exception { + return add(name, value); + } + + /** + * Writes any trailing data that's needed. + * + * @return the generator + * + * @throws Exception if an error occurs while adding the data during the build + */ + Generator end() throws Exception; + + /** + * Indicates whether or not elements in an array should be wrapped or not. The default is {@code false}. + * + * @return {@code true} if elements should be wrapped, otherwise {@code false} + */ + default boolean wrapArrays() { + return false; + } + } +} diff --git a/src/main/java/org/jboss/logmanager/formatters/ValueParser.java b/src/main/java/org/jboss/logmanager/formatters/ValueParser.java new file mode 100644 index 00000000..661ff5e4 --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/ValueParser.java @@ -0,0 +1,144 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jboss.logmanager.config.ValueExpression; + +/** + * A utility for parsing string values into objects. + * + * @author James R. Perkins + */ + +class ValueParser { + + private static final int KEY = 0; + private static final int VALUE = 1; + + /** + * Parses a string of key/value pairs into a map. + *

+ * The key/value pairs are separated by a comma ({@code ,}). The key and value are separated by an equals + * ({@code =}). + *

+ *

+ * If a key contains a {@code \} or an {@code =} it must be escaped by a preceding {@code \}. Example: {@code + * key\==value,\\key=value}. + *

+ *

+ * If a value contains a {@code \} or a {@code ,} it must be escaped by a preceding {@code \}. Example: {@code + * key=part1\,part2,key2=value\\other}. + *

+ * + *

+ * If the value for a key is empty there is no trailing {@code =} after a key the value is assigned an empty + * string. + *

+ * + * @param s the string to parse + * + * @return a map of the key value pairs or an empty map if the string is {@code null} or empty + */ + static Map stringToMap(final String s) { + if (s == null || s.isEmpty()) return Collections.emptyMap(); + + final Map map = new LinkedHashMap<>(); + + final StringBuilder key = new StringBuilder(); + final StringBuilder value = new StringBuilder(); + final char[] chars = s.toCharArray(); + int state = 0; + for (int i = 0; i < chars.length; i++) { + final char c = chars[i]; + switch (state) { + case KEY: { + switch (c) { + case '\\': { + // Handle escapes + if (chars.length > ++i) { + final char next = chars[i]; + if (next == '=' || next == '\\') { + key.append(next); + continue; + } + } + throw new IllegalStateException("Escape character found at invalid position " + i + ". Only characters '=' and '\\' need to be escaped for a key."); + } + case '=': { + state = VALUE; + continue; + } + default: { + key.append(c); + continue; + } + } + } + case VALUE: { + switch (c) { + case '\\': { + // Handle escapes + if (chars.length > ++i) { + final char next = chars[i]; + if (next == ',' || next == '\\') { + value.append(next); + continue; + } + } + throw new IllegalStateException("Escape character found at invalid position " + i + ". Only characters ',' and '\\' need to be escaped for a value."); + } + case ',': { + // Only add if the key isn't empty + if (key.length() > 0) { + // Values may be expressions + final ValueExpression valueExpression = ValueExpression.STRING_RESOLVER.resolve(value.toString()); + map.put(key.toString(), valueExpression.getResolvedValue()); + // Clear the key + key.setLength(0); + } + // Clear the value + value.setLength(0); + state = KEY; + continue; + } + default: { + value.append(c); + continue; + } + } + } + default: + // not reachable + throw new IllegalStateException(); + } + } + // Add the last entry + if (key.length() > 0) { + // Values may be expressions + final ValueExpression valueExpression = ValueExpression.STRING_RESOLVER.resolve(value.toString()); + map.put(key.toString(), valueExpression.getResolvedValue()); + } + return Collections.unmodifiableMap(map); + } +} diff --git a/src/main/java/org/jboss/logmanager/formatters/XmlFormatter.java b/src/main/java/org/jboss/logmanager/formatters/XmlFormatter.java new file mode 100644 index 00000000..a2eaa6bc --- /dev/null +++ b/src/main/java/org/jboss/logmanager/formatters/XmlFormatter.java @@ -0,0 +1,287 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.io.Writer; +import java.util.Collections; +import java.util.Map; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import org.wildfly.common.Assert; + +/** + * A formatter that outputs the record in XML format. + *

+ * The details include; + *

+ *
    + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceClassName() source class name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceFileName() source file name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceMethodName() source method name}
  • + *
  • {@link org.jboss.logmanager.ExtLogRecord#getSourceLineNumber() source line number}
  • + *
+ * + * @author James R. Perkins + */ +@SuppressWarnings({"WeakerAccess", "unused", "SameParameterValue"}) +public class XmlFormatter extends StructuredFormatter { + + /** + * The namespaces for logged records. + */ + public enum Namespace { + LOGGING_1_0("urn:jboss:logmanager:formatter:1.0"); + + private final String uri; + + Namespace(final String uri) { + this.uri = uri; + } + + /** + * Get the URI of this namespace. + * + * @return the URI + */ + public String getUriString() { + return uri; + } + } + + private final XMLOutputFactory factory = XMLOutputFactory.newFactory(); + + private volatile boolean prettyPrint = false; + private volatile boolean printNamespace = false; + private volatile String namespaceUri; + + /** + * Creates a new XML formatter. + */ + public XmlFormatter() { + this(Collections.emptyMap()); + } + + /** + * Creates a new XML formatter. + *

+ * If the {@code keyOverrides} is empty the default {@linkplain Namespace#LOGGING_1_0 namespace} will be used. + *

+ * + * @param keyOverrides a map of the default keys to override + */ + public XmlFormatter(final Map keyOverrides) { + super(Assert.checkNotNullParam("keyOverrides", keyOverrides)); + if (keyOverrides.isEmpty()) { + namespaceUri = Namespace.LOGGING_1_0.getUriString(); + } else { + namespaceUri = null; + } + } + + /** + * Indicates whether or not pretty printing is enabled. + * + * @return {@code true} if pretty printing is enabled, otherwise {@code false} + */ + public boolean isPrettyPrint() { + return prettyPrint; + } + + /** + * Turns on or off pretty printing. + * + * @param prettyPrint {@code true} to turn on pretty printing or {@code false} to turn it off + */ + public void setPrettyPrint(final boolean prettyPrint) { + this.prettyPrint = prettyPrint; + } + + /** + * Indicates whether or not the name space should be written on the {@literal }. + * + * @return {@code true} if the name space should be written for each record + */ + public boolean isPrintNamespace() { + return printNamespace; + } + + /** + * Turns on or off the printing of the namespace for each {@literal }. This is set to + * {@code false} by default. + * + * @param printNamespace {@code true} if the name space should be written for each record + */ + public void setPrintNamespace(final boolean printNamespace) { + this.printNamespace = printNamespace; + } + + /** + * Returns the namespace URI used for each record if {@link #isPrintNamespace()} is {@code true}. + * + * @return the namespace URI, may be {@code null} if explicitly set to {@code null} + */ + public String getNamespaceUri() { + return namespaceUri; + } + + /** + * Sets the namespace URI used for each record if {@link #isPrintNamespace()} is {@code true}. + * + * @param namespace the namespace to use or {@code null} if no namespace URI should be used regardless of the + * {@link #isPrintNamespace()} value + */ + public void setNamespaceUri(final Namespace namespace) { + this.namespaceUri = (namespace == null ? null : namespace.getUriString()); + } + + /** + * Sets the namespace URI used for each record if {@link #isPrintNamespace()} is {@code true}. + * + * @param namespaceUri the namespace to use or {@code null} if no namespace URI should be used regardless of the + * {@link #isPrintNamespace()} value + */ + public void setNamespaceUri(final String namespaceUri) { + this.namespaceUri = namespaceUri; + } + + @Override + protected Generator createGenerator(final Writer writer) throws Exception { + final XMLStreamWriter xmlWriter; + if (prettyPrint) { + xmlWriter = new IndentingXmlWriter(factory.createXMLStreamWriter(writer)); + } else { + xmlWriter = factory.createXMLStreamWriter(writer); + } + return new XmlGenerator(xmlWriter); + } + + private class XmlGenerator implements Generator { + private final XMLStreamWriter xmlWriter; + + private XmlGenerator(final XMLStreamWriter xmlWriter) { + this.xmlWriter = xmlWriter; + } + + @Override + public Generator begin() throws Exception { + writeStart(getKey(Key.RECORD)); + if (printNamespace && namespaceUri != null) { + xmlWriter.writeDefaultNamespace(namespaceUri); + } + return this; + } + + @Override + public Generator add(final String key, final Map value) throws Exception { + if (value == null) { + writeEmpty(key); + } else { + writeStart(key); + for (Map.Entry entry : value.entrySet()) { + final String k = entry.getKey(); + final Object v = entry.getValue(); + if (v == null) { + writeEmpty(k); + } else { + add(k, String.valueOf(v)); + } + } + writeEnd(); + } + return this; + } + + @Override + public Generator add(final String key, final String value) throws Exception { + if (value == null) { + writeEmpty(key); + } else { + writeStart(key); + xmlWriter.writeCharacters(value); + writeEnd(); + } + return this; + } + + @Override + public Generator startObject(final String key) throws Exception { + writeStart(key); + return this; + } + + @Override + public Generator endObject() throws Exception { + writeEnd(); + return this; + } + + @Override + public Generator addAttribute(final String name, final int value) throws Exception { + return addAttribute(name, Integer.toString(value)); + } + + @Override + public Generator addAttribute(final String name, final String value) throws Exception { + xmlWriter.writeAttribute(name, value); + return this; + } + + @Override + public Generator end() throws Exception { + writeEnd(); // end record + safeFlush(xmlWriter); + safeClose(xmlWriter); + return this; + } + + @Override + public boolean wrapArrays() { + return true; + } + + private void writeEmpty(final String name) throws XMLStreamException { + xmlWriter.writeEmptyElement(name); + } + + private void writeStart(final String name) throws XMLStreamException { + xmlWriter.writeStartElement(name); + } + + private void writeEnd() throws XMLStreamException { + xmlWriter.writeEndElement(); + } + + private void safeFlush(final XMLStreamWriter flushable) { + if (flushable != null) try { + flushable.flush(); + } catch (Throwable ignore) { + } + } + + private void safeClose(final XMLStreamWriter closeable) { + if (closeable != null) try { + closeable.close(); + } catch (Throwable ignore) { + } + } + } +} diff --git a/src/main/resources/xml-formatter.xsd b/src/main/resources/xml-formatter.xsd new file mode 100644 index 00000000..e9a0b900 --- /dev/null +++ b/src/main/resources/xml-formatter.xsd @@ -0,0 +1,311 @@ + + + + + + + + + + Defines a log record. + + + + + + + The date and time the log record was recorded. The format is configured via the formatter. + + + + + + + The sequence number of the record. + + + + + + + The name of the logger class that created the message. + + + + + + + The name of the logger. + + + + + + + The level the message was logged at. + + + + + + + The message that was logged. + + + + + + + The name of the thread where the message was logged from. + + + + + + + The threads id where the message was logged from. + + + + + + + Defines the key of the MDC entry as an element with the value being the value of the element. + + + + + + + + + + + + The nested diagnostics for the logged message. + + + + + + + The host name from the record if known. + + + + + + + The process name from the record if known. + + + + + + + The process id from the record if known. + + + + + + + The exception from the logged message. + + + + + + + The stack trace of the exception logged. + + + + + + + The name of the class that (allegedly) issued the logging request. + + + + + + + The source file name. + + + + + + + The name of the method that (allegedly) issued the logging request. + + + + + + + The source line number. + + + + + + + + + + Defines the key of the MDC entry as an element with the value being the value of the element. + + + + + + + + + + + Defines the frame of a stack trace element. + + + + + + + + The type of the exception. + + + + + + + The message from the exception. + + + + + + + + The optional suppressed exceptions. + + + + + + + The optional cause + + + + + + + + + + A reference id. + + + + + + + + + Defines the suppressed exceptions. + + + + + + + + + + + A circular reference to a previous exception in the stack. + + + + + + + + + + + The cause of the exception. + + + + + + + + + + + Defines the frame of a stack trace element. + + + + + + + The fully qualified class name. + + + + + + + The name of the method. + + + + + + + The line the error occurred on. + + + + + + + + + + The exception stack trace frames. + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/jboss/logmanager/formatters/AbstractTest.java b/src/test/java/org/jboss/logmanager/formatters/AbstractTest.java new file mode 100644 index 00000000..1e50a9e5 --- /dev/null +++ b/src/test/java/org/jboss/logmanager/formatters/AbstractTest.java @@ -0,0 +1,80 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jboss.logmanager.ExtLogRecord; +import org.jboss.logmanager.ExtLogRecord.FormatStyle; +import org.junit.Assert; + +/** + * @author James R. Perkins + */ +abstract class AbstractTest { + + ExtLogRecord createLogRecord(final String msg) { + return createLogRecord(org.jboss.logmanager.Level.INFO, msg); + } + + ExtLogRecord createLogRecord(final String format, final Object... args) { + return createLogRecord(org.jboss.logmanager.Level.INFO, format, args); + } + + private ExtLogRecord createLogRecord(final org.jboss.logmanager.Level level, final String msg) { + return new ExtLogRecord(level, msg, getClass().getName()); + } + + ExtLogRecord createLogRecord(final org.jboss.logmanager.Level level, final String format, final Object... args) { + final ExtLogRecord record = new ExtLogRecord(level, format, FormatStyle.PRINTF, getClass().getName()); + record.setParameters(args); + return record; + } + + static void compareMaps(final Map m1, final Map m2) { + String failureMessage = String.format("Keys did not match%n%s%n%s%n", m1.keySet(), m2.keySet()); + Assert.assertTrue(failureMessage, m1.keySet().containsAll(m2.keySet())); + failureMessage = String.format("Values did not match%n%s%n%s%n", m1.values(), m2.values()); + Assert.assertTrue(failureMessage, m1.values().containsAll(m2.values())); + } + + static class MapBuilder { + private final Map result; + + private MapBuilder(final Map result) { + this.result = result; + } + + public static MapBuilder create() { + return new MapBuilder<>(new LinkedHashMap()); + } + + public MapBuilder add(final K key, final V value) { + result.put(key, value); + return this; + } + + Map build() { + return Collections.unmodifiableMap(result); + } + } +} diff --git a/src/test/java/org/jboss/logmanager/formatters/JsonFormatterTests.java b/src/test/java/org/jboss/logmanager/formatters/JsonFormatterTests.java new file mode 100644 index 00000000..e923da28 --- /dev/null +++ b/src/test/java/org/jboss/logmanager/formatters/JsonFormatterTests.java @@ -0,0 +1,234 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.json.JsonValue; +import javax.json.JsonValue.ValueType; + +import org.jboss.logmanager.ExtFormatter; +import org.jboss.logmanager.ExtLogRecord; +import org.jboss.logmanager.Level; +import org.jboss.logmanager.formatters.StructuredFormatter.Key; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +/** + * @author James R. Perkins + */ +public class JsonFormatterTests extends AbstractTest { + private static final Map KEY_OVERRIDES = new HashMap<>(); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter + .ISO_OFFSET_DATE_TIME + .withZone(ZoneId.systemDefault()); + + @Before + public void before() { + KEY_OVERRIDES.clear(); + } + + @Test + public void testFormat() throws Exception { + final JsonFormatter formatter = new JsonFormatter(); + formatter.setPrintDetails(true); + ExtLogRecord record = createLogRecord("Test formatted %s", "message"); + compare(record, formatter); + + record = createLogRecord("Test Message"); + compare(record, formatter); + + record = createLogRecord(Level.ERROR, "Test formatted %s", "message"); + record.setLoggerName("org.jboss.logmanager.ext.test"); + record.setMillis(System.currentTimeMillis()); + final Throwable t = new RuntimeException("Test cause exception"); + final Throwable dup = new IllegalStateException("Duplicate"); + t.addSuppressed(dup); + final Throwable cause = new RuntimeException("Test Exception", t); + dup.addSuppressed(cause); + cause.addSuppressed(new IllegalArgumentException("Suppressed")); + cause.addSuppressed(dup); + record.setThrown(cause); + record.putMdc("testMdcKey", "testMdcValue"); + record.setNdc("testNdc"); + formatter.setExceptionOutputType(JsonFormatter.ExceptionOutputType.DETAILED_AND_FORMATTED); + compare(record, formatter); + } + + @Test + public void testMetaData() throws Exception { + final JsonFormatter formatter = new JsonFormatter(); + formatter.setPrintDetails(true); + formatter.setMetaData("context-id=context1"); + ExtLogRecord record = createLogRecord("Test formatted %s", "message"); + Map metaDataMap = MapBuilder.create() + .add("context-id", "context1") + .build(); + compare(record, formatter, metaDataMap); + + formatter.setMetaData("vendor=Red Hat\\, Inc.,product-type=JBoss"); + metaDataMap = MapBuilder.create() + .add("vendor", "Red Hat, Inc.") + .add("product-type", "JBoss") + .build(); + compare(record, formatter, metaDataMap); + } + + @Test + public void testLogstashFormat() throws Exception { + KEY_OVERRIDES.put(Key.TIMESTAMP, "@timestamp"); + final LogstashFormatter formatter = new LogstashFormatter(); + formatter.setPrintDetails(true); + ExtLogRecord record = createLogRecord("Test formatted %s", "message"); + compareLogstash(record, formatter, 1); + + record = createLogRecord("Test Message"); + formatter.setVersion(2); + compareLogstash(record, formatter, 2); + + record = createLogRecord(Level.ERROR, "Test formatted %s", "message"); + record.setLoggerName("org.jboss.logmanager.ext.test"); + record.setMillis(System.currentTimeMillis()); + record.setThrown(new RuntimeException("Test Exception")); + record.putMdc("testMdcKey", "testMdcValue"); + record.setNdc("testNdc"); + compareLogstash(record, formatter, 2); + } + + private static int getInt(final JsonObject json, final Key key) { + final String name = getKey(key); + if (json.containsKey(name) && !json.isNull(name)) { + return json.getInt(name); + } + return 0; + } + + private static long getLong(final JsonObject json, final Key key) { + final String name = getKey(key); + if (json.containsKey(name) && !json.isNull(name)) { + return json.getJsonNumber(name).longValue(); + } + return 0L; + } + + private static String getString(final JsonObject json, final Key key) { + final String name = getKey(key); + if (json.containsKey(name) && !json.isNull(name)) { + return json.getString(name); + } + return null; + } + + private static Map getMap(final JsonObject json, final Key key) { + final String name = getKey(key); + if (json.containsKey(name) && !json.isNull(name)) { + final Map result = new LinkedHashMap<>(); + final JsonObject mdcObject = json.getJsonObject(name); + for (String k : mdcObject.keySet()) { + final JsonValue value = mdcObject.get(k); + if (value.getValueType() == ValueType.STRING) { + result.put(k, value.toString().replace("\"", "")); + } else { + result.put(k, value.toString()); + } + } + return result; + } + return Collections.emptyMap(); + } + + private static String getKey(final Key key) { + if (KEY_OVERRIDES.containsKey(key)) { + return KEY_OVERRIDES.get(key); + } + return key.getKey(); + } + + private static void compare(final ExtLogRecord record, final ExtFormatter formatter) { + compare(record, formatter.format(record)); + } + + private static void compare(final ExtLogRecord record, final ExtFormatter formatter, final Map metaData) { + compare(record, formatter.format(record), metaData); + } + + private static void compare(final ExtLogRecord record, final String jsonString) { + compare(record, jsonString, null); + } + + private static void compare(final ExtLogRecord record, final String jsonString, final Map metaData) { + final JsonReader reader = Json.createReader(new StringReader(jsonString)); + final JsonObject json = reader.readObject(); + compare(record, json, metaData); + } + + private static void compareLogstash(final ExtLogRecord record, final ExtFormatter formatter, final int version) { + compareLogstash(record, formatter.format(record), version); + } + + private static void compareLogstash(final ExtLogRecord record, final String jsonString, final int version) { + final JsonReader reader = Json.createReader(new StringReader(jsonString)); + final JsonObject json = reader.readObject(); + compare(record, json, null); + final String name = "@version"; + int foundVersion = 0; + if (json.containsKey(name) && !json.isNull(name)) { + foundVersion = json.getInt(name); + } + Assert.assertEquals(version, foundVersion); + } + + private static void compare(final ExtLogRecord record, final JsonObject json, final Map metaData) { + Assert.assertEquals(record.getLevel(), Level.parse(getString(json, Key.LEVEL))); + Assert.assertEquals(record.getLoggerClassName(), getString(json, Key.LOGGER_CLASS_NAME)); + Assert.assertEquals(record.getLoggerName(), getString(json, Key.LOGGER_NAME)); + compareMaps(record.getMdcCopy(), getMap(json, Key.MDC)); + Assert.assertEquals(record.getFormattedMessage(), getString(json, Key.MESSAGE)); + Assert.assertEquals(DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(record.getMillis())), + getString(json, Key.TIMESTAMP)); + Assert.assertEquals(record.getNdc(), getString(json, Key.NDC)); + // Assert.assertEquals(record.getResourceBundle()); + // Assert.assertEquals(record.getResourceBundleName()); + // Assert.assertEquals(record.getResourceKey()); + Assert.assertEquals(record.getSequenceNumber(), getLong(json, Key.SEQUENCE)); + Assert.assertEquals(record.getSourceClassName(), getString(json, Key.SOURCE_CLASS_NAME)); + Assert.assertEquals(record.getSourceFileName(), getString(json, Key.SOURCE_FILE_NAME)); + Assert.assertEquals(record.getSourceLineNumber(), getInt(json, Key.SOURCE_LINE_NUMBER)); + Assert.assertEquals(record.getSourceMethodName(), getString(json, Key.SOURCE_METHOD_NAME)); + Assert.assertEquals(record.getThreadID(), getInt(json, Key.THREAD_ID)); + Assert.assertEquals(record.getThreadName(), getString(json, Key.THREAD_NAME)); + if (metaData != null) { + for (String key : metaData.keySet()) { + Assert.assertEquals(metaData.get(key), json.getString(key)); + } + } + // TODO (jrp) stack trace should be validated + } +} diff --git a/src/test/java/org/jboss/logmanager/formatters/ValueParserTests.java b/src/test/java/org/jboss/logmanager/formatters/ValueParserTests.java new file mode 100644 index 00000000..0f1d4023 --- /dev/null +++ b/src/test/java/org/jboss/logmanager/formatters/ValueParserTests.java @@ -0,0 +1,106 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +/** + * @author James R. Perkins + */ +public class ValueParserTests extends AbstractTest { + + @Test + public void testStringToMap() { + Map map = MapBuilder.create() + .add("key1", "value1") + .add("key2", "value2") + .add("key3", "value3") + .build(); + Map parsedMap = ValueParser.stringToMap("key1=value2,key2=value2,key3=value3"); + compareMaps(map, parsedMap); + + map = MapBuilder.create() + .add("key=1", "value1") + .add("key=2", "value,2") + .add("key3", "value,3") + .build(); + parsedMap = ValueParser.stringToMap("key\\=1=value1,key\\=2=value\\,2,key3=value\\,3"); + compareMaps(map, parsedMap); + + map = MapBuilder.create() + .add("key=", "value,") + .add("key2", "value2") + .add("key\\", "value\\") + .add("this", "some=thing\\thing=some") + .build(); + parsedMap = ValueParser.stringToMap("key\\==value\\,,key2=value2,key\\\\=value\\\\,this=some=thing\\\\thing=some"); + compareMaps(map, parsedMap); + + map = MapBuilder.create() + .add("key1", "value1") + .add("key2", "") + .add("key3", "value3") + .add("key4", "") + .build(); + parsedMap = ValueParser.stringToMap("key1=value1,key2=,key3=value3,key4"); + compareMaps(map, parsedMap); + + map = MapBuilder.create() + .add("company", "Red Hat, Inc.") + .add("product", "JBoss") + .add("name", "First \"nick\" Last") + .build(); + parsedMap = ValueParser.stringToMap("company=Red Hat\\, Inc.,product=JBoss,name=First \"nick\" Last"); + compareMaps(map, parsedMap); + + Assert.assertTrue("Map is not empty", ValueParser.stringToMap(null).isEmpty()); + Assert.assertTrue("Map is not empty", ValueParser.stringToMap("").isEmpty()); + } + + @Test + public void testStringToMapValueExpressions() { + System.setProperty("org.jboss.logmanager.test.sysprop1", "test-value"); + System.setProperty("org.jboss.logmanager.test.sysprop2", "test-value2"); + Map map = MapBuilder.create() + .add("key1", "test-value") + .add("key2=", "test-value2") + .build(); + Map parsedMap = ValueParser.stringToMap("key1=${org.jboss.logmanager.test.sysprop1},key2\\==${org.jboss.logmanager.test.sysprop2}"); + compareMaps(map, parsedMap); + + map = MapBuilder.create() + .add("key1", "test-value") + .add("key2", "default-value") + .build(); + parsedMap = ValueParser.stringToMap("key1=${org.jboss.logmanager.test.sysprop1},key2=${org.jboss.logmanager.test.missing:default-value}"); + compareMaps(map, parsedMap); + + System.setProperty("org.jboss.logmanager.test.sysprop1", "test-value,next"); + map = MapBuilder.create() + .add("key1", "test-value,next") + .add("key2", "test-value2,next") + .build(); + parsedMap = ValueParser.stringToMap("key1=${org.jboss.logmanager.test.sysprop1},key2=${org.jboss.logmanager.test.sysprop2}\\,next"); + compareMaps(map, parsedMap); + } +} diff --git a/src/test/java/org/jboss/logmanager/formatters/XmlFormatterTests.java b/src/test/java/org/jboss/logmanager/formatters/XmlFormatterTests.java new file mode 100644 index 00000000..07f10e80 --- /dev/null +++ b/src/test/java/org/jboss/logmanager/formatters/XmlFormatterTests.java @@ -0,0 +1,279 @@ +/* + * JBoss, Home of Professional Open Source. + * + * Copyright 2017 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * Licensed 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. + */ + +package org.jboss.logmanager.formatters; + +import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.xml.XMLConstants; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; +import javax.xml.validation.Validator; + +import org.jboss.logmanager.ExtFormatter; +import org.jboss.logmanager.ExtLogRecord; +import org.jboss.logmanager.Level; +import org.jboss.logmanager.formatters.StructuredFormatter.Key; +import org.junit.Assert; +import org.junit.Test; +import org.xml.sax.ErrorHandler; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; + +/** + * @author James R. Perkins + */ +public class XmlFormatterTests extends AbstractTest { + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter + .ISO_OFFSET_DATE_TIME + .withZone(ZoneId.systemDefault()); + + @Test + public void validate() throws Exception { + // Configure the formatter + final XmlFormatter formatter = new XmlFormatter(); + formatter.setPrintNamespace(true); + formatter.setPrintDetails(true); + formatter.setExceptionOutputType(StructuredFormatter.ExceptionOutputType.DETAILED_AND_FORMATTED); + // Create the record get format a message + final ExtLogRecord record = createLogRecord(Level.ERROR, "Test formatted %s", "message"); + record.setLoggerName("org.jboss.logmanager.ext.test"); + record.setMillis(System.currentTimeMillis()); + record.setThrown(createMultiNestedCause()); + record.putMdc("testMdcKey", "testMdcValue"); + record.setNdc("testNdc"); + final String message = formatter.format(record); + + final ErrorHandler handler = new ErrorHandler() { + @Override + public void warning(final SAXParseException exception) throws SAXException { + fail(exception); + } + + @Override + public void error(final SAXParseException exception) throws SAXException { + fail(exception); + } + + @Override + public void fatalError(final SAXParseException exception) throws SAXException { + fail(exception); + } + + private void fail(final SAXParseException exception) { + final StringBuilder failureMessage = new StringBuilder(); + failureMessage.append(exception.getLocalizedMessage()) + .append(": line ") + .append(exception.getLineNumber()) + .append(" column ") + .append(exception.getColumnNumber()) + .append(System.lineSeparator()) + .append(message); + Assert.fail(failureMessage.toString()); + } + }; + + final SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + factory.setErrorHandler(handler); + + final Schema schema = factory.newSchema(getClass().getResource("/xml-formatter.xsd")); + final Validator validator = schema.newValidator(); + validator.setErrorHandler(handler); + validator.setFeature("http://apache.org/xml/features/validation/schema", true); + validator.validate(new StreamSource(new StringReader(message))); + } + + @Test + public void testFormat() throws Exception { + final XmlFormatter formatter = new XmlFormatter(); + formatter.setPrintDetails(true); + ExtLogRecord record = createLogRecord("Test formatted %s", "message"); + compare(record, formatter); + + record = createLogRecord("Test Message"); + compare(record, formatter); + + record = createLogRecord(Level.ERROR, "Test formatted %s", "message"); + record.setLoggerName("org.jboss.logmanager.ext.test"); + record.setMillis(System.currentTimeMillis()); + final Throwable t = new RuntimeException("Test cause exception"); + final Throwable dup = new IllegalStateException("Duplicate"); + t.addSuppressed(dup); + final Throwable cause = new RuntimeException("Test Exception", t); + dup.addSuppressed(cause); + cause.addSuppressed(new IllegalArgumentException("Suppressed")); + cause.addSuppressed(dup); + record.setThrown(cause); + record.putMdc("testMdcKey", "testMdcValue"); + record.setNdc("testNdc"); + formatter.setExceptionOutputType(JsonFormatter.ExceptionOutputType.DETAILED_AND_FORMATTED); + compare(record, formatter); + } + + private static int getInt(final XMLStreamReader reader) throws XMLStreamException { + final String value = getString(reader); + if (value != null) { + return Integer.parseInt(value); + } + return 0; + } + + private static long getLong(final XMLStreamReader reader) throws XMLStreamException { + final String value = getString(reader); + if (value != null) { + return Long.parseLong(value); + } + return 0L; + } + + private static String getString(final XMLStreamReader reader) throws XMLStreamException { + final int state = reader.next(); + if (state == XMLStreamConstants.END_ELEMENT) { + return null; + } + if (state == XMLStreamConstants.CHARACTERS) { + final String text = reader.getText(); + return sanitize(text); + } + throw new IllegalStateException("No text"); + } + + private static Map getMap(final XMLStreamReader reader) throws XMLStreamException { + if (reader.hasNext()) { + int state; + final Map result = new LinkedHashMap<>(); + while (reader.hasNext() && (state = reader.next()) != XMLStreamConstants.END_ELEMENT) { + if (state == XMLStreamConstants.CHARACTERS) { + String text = sanitize(reader.getText()); + if (text == null || text.isEmpty()) continue; + Assert.fail(String.format("Invalid text found: %s", text)); + } + final String key = reader.getLocalName(); + Assert.assertTrue(reader.hasNext()); + final String value = getString(reader); + Assert.assertNotNull(value); + result.put(key, value); + } + return result; + } + return Collections.emptyMap(); + } + + private static String sanitize(final String value) { + return value == null ? null : value.replaceAll("\n", "").trim(); + } + + private static void compare(final ExtLogRecord record, final ExtFormatter formatter) throws XMLStreamException { + compare(record, formatter.format(record)); + } + + private static void compare(final ExtLogRecord record, final String xmlString) throws XMLStreamException { + + final XMLInputFactory inputFactory = XMLInputFactory.newInstance(); + final XMLStreamReader reader = inputFactory.createXMLStreamReader(new StringReader(xmlString)); + + boolean inException = false; + while (reader.hasNext()) { + final int state = reader.next(); + if (state == XMLStreamConstants.END_ELEMENT && reader.getLocalName().equals(Key.EXCEPTION.getKey())) { + inException = false; + } + if (state == XMLStreamConstants.START_ELEMENT) { + final String localName = reader.getLocalName(); + if (localName.equals(Key.EXCEPTION.getKey())) { + inException = true;// TODO (jrp) stack trace may need to be validated + } else if (localName.equals(Key.LEVEL.getKey())) { + Assert.assertEquals(record.getLevel(), Level.parse(getString(reader))); + } else if (localName.equals(Key.LOGGER_CLASS_NAME.getKey())) { + Assert.assertEquals(record.getLoggerClassName(), getString(reader)); + } else if (localName.equals(Key.LOGGER_NAME.getKey())) { + Assert.assertEquals(record.getLoggerName(), getString(reader)); + } else if (localName.equals(Key.MDC.getKey())) { + compareMap(record.getMdcCopy(), getMap(reader)); + } else if (!inException && localName.equals(Key.MESSAGE.getKey())) { + Assert.assertEquals(record.getFormattedMessage(), getString(reader)); + } else if (localName.equals(Key.NDC.getKey())) { + final String value = getString(reader); + Assert.assertEquals(record.getNdc(), (value == null ? "" : value)); + } else if (localName.equals(Key.SEQUENCE.getKey())) { + Assert.assertEquals(record.getSequenceNumber(), getLong(reader)); + } else if (localName.equals(Key.SOURCE_CLASS_NAME.getKey())) { + Assert.assertEquals(record.getSourceClassName(), getString(reader)); + } else if (localName.equals(Key.SOURCE_FILE_NAME.getKey())) { + Assert.assertEquals(record.getSourceFileName(), getString(reader)); + } else if (localName.equals(Key.SOURCE_LINE_NUMBER.getKey())) { + Assert.assertEquals(record.getSourceLineNumber(), getInt(reader)); + } else if (localName.equals(Key.SOURCE_METHOD_NAME.getKey())) { + Assert.assertEquals(record.getSourceMethodName(), getString(reader)); + } else if (localName.equals(Key.THREAD_ID.getKey())) { + Assert.assertEquals(record.getThreadID(), getInt(reader)); + } else if (localName.equals(Key.THREAD_NAME.getKey())) { + Assert.assertEquals(record.getThreadName(), getString(reader)); + } else if (localName.equals(Key.TIMESTAMP.getKey())) { + final String dateTime = DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(record.getMillis())); + Assert.assertEquals(dateTime, getString(reader)); + } + } + } + } + + private static void compareMap(final Map m1, final Map m2) { + Assert.assertEquals("Map sizes do not match", m1.size(), m2.size()); + for (String key : m1.keySet()) { + Assert.assertTrue("Second map does not contain key " + key, m2.containsKey(key)); + Assert.assertEquals(m1.get(key), m2.get(key)); + } + } + + private static Throwable createMultiNestedCause() { + final RuntimeException suppressed1 = new RuntimeException("Suppressed 1"); + final IllegalStateException nested1 = new IllegalStateException("Nested 1"); + nested1.addSuppressed(new RuntimeException("Nested 1a")); + suppressed1.addSuppressed(nested1); + suppressed1.addSuppressed(new IllegalStateException("Nested 1-2")); + + final RuntimeException suppressed2 = new RuntimeException("Suppressed 2", suppressed1); + final IllegalStateException nested2 = new IllegalStateException("Nested 2"); + nested2.addSuppressed(new RuntimeException("Nested 2a")); + suppressed2.addSuppressed(nested2); + suppressed2.addSuppressed(new IllegalStateException("Nested 2-2")); + + final RuntimeException suppressed3 = new RuntimeException("Suppressed 3"); + final IllegalStateException nested3 = new IllegalStateException("Nested 3"); + nested3.addSuppressed(new RuntimeException("Nested 3a")); + suppressed3.addSuppressed(nested3); + suppressed3.addSuppressed(new IllegalStateException("Nested 3-2")); + + final RuntimeException cause = new RuntimeException("This is the cause"); + cause.addSuppressed(suppressed1); + cause.addSuppressed(suppressed2); + cause.addSuppressed(suppressed3); + return cause; + } +}