Skip to content

ECS structure logging is not compatible with all collectors as it does not use the nested format #45063

Closed
@kajh

Description

@kajh

It would be nice with an option to get ecs logging as nested json like

{ "ecs":  {"version": "8.4"}, "service": {"name": "myservice"}}

instead of what we get today

{ "ecs.version": "8.4", "service.name" : "myservice"}

The reason for asking is that collecting and processing of logs would be easier.

We are using Spring Boot 3.4.4.

Activity

nosan

nosan commented on Apr 10, 2025

@nosan
Contributor

It would be nice with an option to get ecs logging as nested json like

I'm not sureif this is advisable, as it conflicts with the ECS Schema. Currently, ECS Schema does
not support a format such as "ecs": {"version":"8.11"}. Moreover, according to the ECS Schema, the
ecs.version field is required and must exist in all events, so supporting this functionality
would not provide any value from the user's perspective.

UPDATE: My previous statement was incorrect.

The reason for asking is that collecting and processing of logs would be easier.

For your specific case, you should create a custom implementation of JsonWriterStructuredLogFormatter with a format that suits exactly to your needs. This custom formatter can then be configured using the logging.structured.format.console property.

For more details, please refer to the Spring Boot documentation.

The following NestedElasticCommonSchemaStructuredLogFormatter might suit your needs:

package task.gh45063;

import ch.qos.logback.classic.pattern.ThrowableProxyConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import org.slf4j.event.KeyValuePair;
import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.json.JsonWriter.PairExtractor;
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties;
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties.Service;
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
import org.springframework.boot.logging.structured.StructuredLoggingJsonMembersCustomizer;
import org.springframework.core.env.Environment;

import java.util.Objects;

class NestedElasticCommonSchemaStructuredLogFormatter extends
        JsonWriterStructuredLogFormatter<ILoggingEvent> {

    private static final PairExtractor<KeyValuePair> keyValuePairExtractor = PairExtractor.of(
            (pair) -> pair.key,
            (pair) -> pair.value);

    NestedElasticCommonSchemaStructuredLogFormatter(Environment environment,
            ThrowableProxyConverter throwableProxyConverter,
            StructuredLoggingJsonMembersCustomizer<?> customizer) {
        super((members) -> jsonMembers(environment, throwableProxyConverter, members), customizer);
    }

    private static void jsonMembers(Environment environment,
            ThrowableProxyConverter throwableProxyConverter,
            JsonWriter.Members<ILoggingEvent> members) {
        members.add("@timestamp", ILoggingEvent::getInstant);
        members.add("log").usingMembers((logMembers) -> {
                    logMembers.add("logger", ILoggingEvent::getLoggerName);
                    logMembers.add("level", ILoggingEvent::getLevel);
                }
        );
        members.add("process").usingMembers((processMembers) -> {
            processMembers.add("pid", environment.getProperty("spring.application.pid", Long.class))
                    .when(Objects::nonNull);
            processMembers.add("thread")
                    .usingMembers((threadMembers) -> threadMembers.add("name",
                            ILoggingEvent::getThreadName));
        });
        Service service = ElasticCommonSchemaProperties.get(environment).service();
        members.add("service").usingMembers((serviceMembers) -> {
            serviceMembers.add("name", service::name).whenHasLength();
            serviceMembers.add("version", service::version).whenHasLength();
            serviceMembers.add("environment", service::environment).whenHasLength();
            serviceMembers.add("node.name", service::nodeName).whenHasLength();
        });
        members.add("message", ILoggingEvent::getFormattedMessage);
        members.addMapEntries(ILoggingEvent::getMDCPropertyMap);
        members.from(ILoggingEvent::getKeyValuePairs)
                .whenNotEmpty()
                .usingExtractedPairs(Iterable::forEach, keyValuePairExtractor);
        members.add("error").whenNotNull(ILoggingEvent::getThrowableProxy)
                .usingMembers((throwableMembers) -> {
                    throwableMembers.add("type", ILoggingEvent::getThrowableProxy)
                            .as(IThrowableProxy::getClassName);
                    throwableMembers.add("message", ILoggingEvent::getThrowableProxy)
                            .as(IThrowableProxy::getMessage);
                    throwableMembers.add("stack_trace", throwableProxyConverter::convert);
                });
        members.add("ecs").usingMembers((ecsMembers) -> ecsMembers.add("version", "8.11"));
    }

}
{
  "@timestamp": "2025-04-10T14:35:20.081935Z",
  "log": {
    "logger": "task.gh45063.Gh45063Application",
    "level": "INFO"
  },
  "process": {
    "pid": 25264,
    "thread": {
      "name": "main"
    }
  },
  "service": {
    "name": "gh-45063"
  },
  "message": "Started Gh45063Application in 0.257 seconds (process running for 0.418)",
  "ecs": {
    "version": "8.11"
  }
}

snicoll

snicoll commented on Apr 10, 2025

@snicoll
added
status: declinedA suggestion or change that we don't feel we should currently apply
and removed on Apr 10, 2025
rafaelma

rafaelma commented on Apr 10, 2025

@rafaelma

Hello, thanks for looking at this :)

ECS stores data in a nested format, our example will be saved as { "ecs": {"version": "8.4"} }, and the mapping definition of the ECS standard needed by e.g. elasticsearch or opensearch defines also the nested format. It’s important to note that the translation from { "ecs.version": "8.4" } to { "ecs": {"version": "8.4"} } before saving the data according to the ECS standard is done by the Elastic Agent using the decode_json_fields processor in the elastic agent ref: https://www.elastic.co/guide/en/fleet/current/decode-json-fields.html.

For reference, you can check the mapping representation of the ECS standard for our example here: https://github.com/elastic/ecs/blob/main/generated/elasticsearch/composable/component/ecs.json The ECS documentation https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html refers to attributes like ecs.version, but this is merely a simplified representation of a JSON nested attribute in the documentation for presentation purposes.

If you use another agent than the elastic-agent (e.g.fluent-bit) to process logs and spring delivers them as e.g. { "ecs.version": "8.4" }, you have to transform this to the nested version { "ecs": {"version": "8.4"} } before you can save it according to the standard.

Spring doesn't deliver the logs according to the ECS standard format and it will only work without problems if you collect these logs with the elastic-agent that will convert them to the right ECS format before sending them to storage.

regards,
Rafael Martinez Guerrero

added and removed
status: declinedA suggestion or change that we don't feel we should currently apply
on Apr 10, 2025
marked ECS standard #45132 as a duplicate of this issue on Apr 10, 2025
nosan

nosan commented on Apr 10, 2025

@nosan
Contributor

I set up Elasticsearch via https://www.elastic.co/cloud and performed another round of testing.

It's important to clarify that Spring does not directly produce logs in the ECS standard; typically, logs require the Elastic Agent to properly transform them into ECS format before indexing.

However, in practice, the JSON format currently produced by ElasticCommonSchemaStructuredLogFormatter works seamlessly. Elasticsearch accepts them without issue, correctly parsing all fields.

Flat JSON example:

{
  "@timestamp": "2025-04-10T18:03:42.230354Z",
  "log.level": "ERROR",
  "process.pid": 30859,
  "process.thread.name": "main",
  "service.name": "gh-45063",
  "log.logger": "task.gh45063.Gh45063Application",
  "message": "MyError",
  "error.type": "java.lang.IllegalStateException",
  "error.message": "Error Test",
  "error.stack_trace": "java.lang.IllegalStateException: Error Test\n\tat task.gh45063.Gh45063Application.main(Gh45063Application.java:14)\n",
  "ecs.version": "8.11"
}

Additionally, Elasticsearch fully supports and parses nested JSON structures, like the one below:

Nested JSON example:

{
  "@timestamp": "2025-04-10T18:04:14.257693Z",
  "log": {
    "logger": "task.gh45063.Gh45063Application",
    "level": "ERROR"
  },
  "process": {
    "pid": 30877,
    "thread": {
      "name": "main"
    }
  },
  "service": {
    "name": "gh-45063"
  },
  "message": "MyError",
  "error": {
    "type": "java.lang.IllegalStateException",
    "message": "Error Test",
    "stack_trace": "java.lang.IllegalStateException: Error Test\n\tat task.gh45063.Gh45063Application.main(Gh45063Application.java:14)\n"
  },
  "ecs": {
    "version": "8.11"
  }
}

In summary, both flat and nested formats are compatible.

Screenshots Image Image Image
rafaelma

rafaelma commented on Apr 10, 2025

@rafaelma

Thank you, @nosan, for investigating this issue further.

"...In summary, both flat and nested formats are compatible ....", as a source, but only when using Elasticsearch as the storage infrastructure because elasticsearch transforms flat format to the standard nested format before saving the log. If you examine the JSON code of the document saved by Elasticsearch after sending the flat version, you'll see it has been also converted into a nested json document to adhere to the standard.

UPDATE:


If elasticsearch works as opensearch this statement is not true: "If you examine the JSON code of the document saved by Elasticsearch after sending the flat version, you'll see it has been also converted into a nested json document to adhere to the standard"

OpenSearch accepts both flat and nested JSON with a nested mapping definition now, but the saved flat JSON document is not converted to nested format.

Check #45063 (comment) for full details.


The ECS standard is now utilized by many products following the merger of OpenTelemetry and Elastic standards to create a unified open standard. Refer to the following links for more information:

It would greatly enhance the Spring project if it could deliver logs in the ECS nested format. This would enable standardized log storage without the need to convert from flat to nested format before saving the log, when used with products other than Elasticsearch, such as Opensearch.

The conversion from flat to nested format to be able to save the standardized log, can be quite tedious and resource-intensive when processing millions of logs if you don't use elasticsearch.

regards

nosan

nosan commented on Apr 10, 2025

@nosan
Contributor

According to the ECS Reference:

The document structure should be nested JSON objects. If you use Beats or Logstash, the nesting of JSON objects is done for you automatically. If you’re ingesting to Elasticsearch using the API, your fields must be nested objects, not strings containing dots.

I also checked the ECS Logging Java repository elastic/ecs-logging-java and found they have a similar issues:

However, the ECS Java logging library continues to use dot notation and relies on this processor afterward. It seems they chose to keep dot notation because they can't ensure that all fields are correctly nested especially considering fields provided through ILoggingEvent.getKeyValuePairs() or ILoggingEvent.getMDCPropertyMap(), since these may contain dots as well, and for example if you set a custom field using MDC like this MDC.put("geo.continent_name", "North America")
then according to the ECS Reference , the ElasticCommonSchemaStructuredLogFormatter should ideally convert this into nested JSON, as shown below:

{
  "geo": {
    "continent_name": "North America"
  }
}

Personally, if Spring Boot chooses to support the nested JSON format, I don't see any advantage in maintaining support for the current format, as only the nested JSON format fully complies with the ECS specification.

when used with products other than Elasticsearch, such as Opensearch.

I set up OpenSearch using Docker Compose and submitted several JSON documents to it. Both formats are supported by OpenSearch.

ScreenshotImage

I hope I haven't overlooked anything, and I apologize once again for my earlier incorrect comment: #45063 (comment)

24 remaining items

kajh

kajh commented on Apr 16, 2025

@kajh
Author

We have now tested with the latest snapshot and it seems to work very well :)

However, we have found something that might be a bug. When using MDC with a key named error we get the stacktrace bellow, f.x.

MDC.put("error", "demo");

I have made a demo project in which you can reproduce this by starting the app and go to http://localhost:8080/index.html.

demo.zip

{"@timestamp":"2025-04-16T11:15:43.255694Z","log":{"level":"INFO","logger":"org.springframework.web.servlet.DispatcherServlet"},"process":{"pid":78113,"thread":{"name":"http-nio-8080-exec-1"}},"service":{"name":"demo","node":{}},"message":"Completed initialization in 1 ms","ecs":{"version":"8.11"}}
13:15:43,275 |-ERROR in ch.qos.logback.core.ConsoleAppender[CONSOLE] - Appender [CONSOLE] failed to append. java.lang.IllegalStateException: The name 'error' has already been written
	at java.lang.IllegalStateException: The name 'error' has already been written
	at 	at org.springframework.util.Assert.state(Assert.java:101)
	at 	at org.springframework.boot.json.JsonValueWriter.writePair(JsonValueWriter.java:228)
	at 	at org.springframework.boot.json.JsonValueWriter.write(JsonValueWriter.java:83)
	at 	at org.springframework.boot.json.JsonWriter$Member.write(JsonWriter.java:650)
	at 	at org.springframework.boot.json.JsonWriter$Members.write(JsonWriter.java:339)
	at 	at org.springframework.boot.json.JsonWriter$Member.lambda$getValueToWrite$6(JsonWriter.java:658)
	at 	at org.springframework.boot.json.WritableJson$1.to(WritableJson.java:164)
	at 	at org.springframework.boot.json.JsonValueWriter.write(JsonValueWriter.java:111)
	at 	at org.springframework.boot.json.JsonValueWriter.write(JsonValueWriter.java:86)
	at 	at org.springframework.boot.json.JsonWriter$Member.write(JsonWriter.java:650)
	at 	at org.springframework.boot.json.JsonWriter$Members.write(JsonWriter.java:339)
	at 	at org.springframework.boot.json.JsonWriter.lambda$of$2(JsonWriter.java:153)
	at 	at org.springframework.boot.json.JsonWriter.lambda$withSuffix$1(JsonWriter.java:126)
	at 	at org.springframework.boot.json.JsonWriter.lambda$write$0(JsonWriter.java:103)
	at 	at org.springframework.boot.json.WritableJson$1.to(WritableJson.java:164)
	at 	at org.springframework.boot.json.WritableJson.toWriter(WritableJson.java:149)
	at 	at org.springframework.boot.json.WritableJson.toByteArray(WritableJson.java:80)
	at 	at org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter.formatAsBytes(JsonWriterStructuredLogFormatter.java:76)
	at 	at org.springframework.boot.logging.logback.StructuredLogEncoder.encode(StructuredLogEncoder.java:131)
	at 	at org.springframework.boot.logging.logback.StructuredLogEncoder.encode(StructuredLogEncoder.java:46)
	at 	at ch.qos.logback.core.OutputStreamAppender.writeOut(OutputStreamAppender.java:203)
	at 	at ch.qos.logback.core.OutputStreamAppender.subAppend(OutputStreamAppender.java:257)
	at 	at ch.qos.logback.core.OutputStreamAppender.append(OutputStreamAppender.java:111)
	at 	at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:85)
	at 	at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:51)
	at 	at ch.qos.logback.classic.Logger.appendLoopOnAppenders(Logger.java:272)
	at 	at ch.qos.logback.classic.Logger.callAppenders(Logger.java:259)
	at 	at ch.qos.logback.classic.Logger.buildLoggingEventAndAppend(Logger.java:426)
	at 	at ch.qos.logback.classic.Logger.filterAndLog_0_Or3Plus(Logger.java:386)
	at 	at ch.qos.logback.classic.Logger.error(Logger.java:543)
	at 	at com.example.demo.ThrowingExceptionController.handleException(ThrowingExceptionController.java:33)
	at 	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at 	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at 	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258)
	at 	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191)
	at 	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
	at 	at org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.doResolveHandlerMethodException(ExceptionHandlerExceptionResolver.java:471)
	at 	at org.springframework.web.servlet.handler.AbstractHandlerMethodExceptionResolver.doResolveException(AbstractHandlerMethodExceptionResolver.java:73)
	at 	at org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver.resolveException(AbstractHandlerExceptionResolver.java:182)
	at 	at org.springframework.web.servlet.handler.HandlerExceptionResolverComposite.resolveException(HandlerExceptionResolverComposite.java:80)
	at 	at org.springframework.web.servlet.DispatcherServlet.processHandlerException(DispatcherServlet.java:1359)
	at 	at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1161)
	at 	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1106)
	at 	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
	at 	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
	at 	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
	at 	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
	at 	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
	at 	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
	at 	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)
	at 	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at 	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
	at 	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at 	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at 	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	at 	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at 	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at 	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at 	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
	at 	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at 	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at 	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at 	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
	at 	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at 	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at 	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at 	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)
	at 	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
	at 	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)
	at 	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:116)
	at 	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
	at 	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
	at 	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)
	at 	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:398)
	at 	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
	at 	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:903)
	at 	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1740)
	at 	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
	at 	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1189)
	at 	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:658)
	at 	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
	at 	at java.base/java.lang.Thread.run(Thread.java:1575)
wilkinsona

wilkinsona commented on Apr 16, 2025

@wilkinsona
Member

@kajh, thanks for trying the snapshot. The problem with an error entry in the MDC seems to be unrelated to nested vs flat structure or do you only see this behavior with 3.5 snapshots?

wilkinsona

wilkinsona commented on Apr 16, 2025

@wilkinsona
Member

Answering my own question, this seems to be a regression in 3.5.0-SNAPSHOT. I don't see the problem with 3.5.0-M3 or 3.4.4. I'll re-open this one while we track down the cause.

kajh

kajh commented on Apr 16, 2025

@kajh
Author

Answering my own question, this seems to be a regression in 3.5.0-SNAPSHOT. I don't see the problem with 3.5.0-M3 or 3.4.4. I'll re-open this one while we track down the cause.

Thank you :)

wilkinsona

wilkinsona commented on Apr 16, 2025

@wilkinsona
Member

Prior to this change, the JSON looks like this:

{
    "@timestamp": "2024-07-02T08:49:53Z",
    "log.level": "ERROR",
    "process.thread.name": "main",
    "log.logger": "org.example.Test",
    "message": "message",
    "error.type": "java.lang.RuntimeException",
    "error.message": "Boom!",
    "error.stack_trace": "java.lang.RuntimeException: Boom!\n\tat org.springframework.boot.logging.logback.StructuredLogEncoderTests.shouldSupportEcsCommonFormat(StructuredLogEncoderTests.java:76)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:569)\n\tat org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:767)\n\tat org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)\n\tat org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)\n\tat org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156)\n\tat org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147)\n\tat org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86)\n\tat org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103)\n\tat org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93)\n\tat org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)\n\tat org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)\n\tat org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)\n\tat org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)\n\tat org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92)\n\tat org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86)\n\tat org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$8(TestMethodTestDescriptor.java:217)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213)\n\tat org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138)\n\tat org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:156)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:146)\n\tat org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:144)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:143)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:100)\n\tat java.base/java.util.ArrayList.forEach(ArrayList.java:1511)\n\tat org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:160)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:146)\n\tat org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:144)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:143)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:100)\n\tat java.base/java.util.ArrayList.forEach(ArrayList.java:1511)\n\tat org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:160)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:146)\n\tat org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:144)\n\tat org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:143)\n\tat org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:100)\n\tat org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)\n\tat org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)\n\tat org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:198)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:169)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:93)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:58)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:141)\n\tat org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:57)\n\tat org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:103)\n\tat org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:94)\n\tat org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:52)\n\tat org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:70)\n\tat org.eclipse.jdt.internal.junit5.runner.JUnit5TestReference.run(JUnit5TestReference.java:100)\n\tat org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:40)\n\tat org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:529)\n\tat org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:757)\n\tat org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:452)\n\tat org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:210)\n",
    "ecs.version": "8.11"
}

With this change, the JSON looks like this:

{
    "@timestamp": "2024-07-02T08:49:53Z",
    "log":
    {
        "level": "ERROR",
        "logger": "org.example.Test"
    },
    "process":
    {
        "thread":
        {
            "name": "main"
        }
    },
    "service":
    {
        "node":
        {}
    },
    "message": "message",
    "error":
    {
        "type": "java.lang.RuntimeException",
        "message": "Boom!",
        "stack_trace": "stacktrace:RuntimeException"
    },
    "ecs":
    {
        "version": "8.11"
    }
}

Technically, this is a breaking change as an MDC entry named error will now clash with the error object in the JSON just as an MDC entry named error.type would have clashed previously.

Given the Map<String, String> nature of the MDC and it therefore being compatible with the keyword data type, I wonder if we should be using the labels field here.

nosan

nosan commented on Apr 16, 2025

@nosan
Contributor

Hmm... I am not sure if it's a valid case to use MDC.put("error", "value"), as it goes against the ECS error type defined in ecs-error. The error should be a JSON object, not a string value.

Elastic Error Validation
{
  "error": {
    "root_cause": [
      {
        "type": "document_parsing_exception",
        "reason": "[9:12] object mapping for [error] tried to parse field [error] as object, but found a concrete value"
      }
    ],
    "type": "document_parsing_exception",
    "reason": "[9:12] object mapping for [error] tried to parse field [error] as object, but found a concrete value"
  },
  "status": 400
}

Technically, this is a breaking change...

The nested format itself is a breaking change and will affect any users who upgrade to 3.5.0. I believe this information should be included in the noteworthy section.

Given the Map<String, String> nature of the MDC and it therefore being compatible with the keyword data type, I wonder if we should be using the labels field here.

It is a good option, but doing so would prevent the use of MDC.put("geo.continent_name", "North America") with the expectation that Elastic will recognize it as ecs-geo.

You can set the error field not only through MDC but also using ILoggingEvent::getKeyValuePairs, for example: logger.atError().addKeyValue("error", "demo").log("Some Error", ex)


Here are a few observations:

  • Presence of empty JSON objects, for example: "service": { "node": {} }.
  • Inconsistent behavior depending on the presence of a Throwable:
logger.atError().addKeyValue("error", "demo").log("Runtime Error"); // no errors
logger.atError().addKeyValue("error", "demo").log("Runtime Error", new RuntimeException("ex")); // The name 'error' has already been written
MDC.put("error", "demo");
logger.error("Runtime Error"); //no errors
logger.error("Runtime Error", new RuntimeException("ex")); // The name 'error' has already been written
kajh

kajh commented on Apr 16, 2025

@kajh
Author

I would just start to say as a disclaimer that I'm very new to ECS. After reading the comments to my bug report regarding using the key error, I start to believe that can be looked at as a user error.

We have the following code in our error handler:

MDC.put("url.original", request.getRequestURL().toString());
MDC.put("error.type", e.getClass().getSimpleName());

logger.error(errorMessage, e);

I was not aware that Spring automatically added error.type so I think the fix for this is to remove error.type line above from our code.

What might be a bug is that the The name 'error' has already been written error message is not written in ECS format. For us, I think that means it will not be imported into Opensearch, so we will be able to know what has gone wrong.

Do you agree that it should be logged as ECS?

Just to describe two different ways we use MDC:

  1. We enrich the logging with ECS attributes like url.original as in the code above.
  2. We enrich the logging with custom attributes fx describing the object the using is requesting with its id and type.

Thank you!

philwebb

philwebb commented on Apr 17, 2025

@philwebb
Member

What might be a bug is that the The name 'error' has already been written error message is not written in ECS format. For us, I think that means it will not be imported into Opensearch, so we will be able to know what has gone wrong

The reason we don't currently write the error message in ECS format is because we assuming that writing any other ECS message might fail in the same way. That's why we currently throw an exception and let the logging system report it directly to the console. I've opened #45217 to see if we can log the message without the context data if an exception occurs.

I was not aware that Spring automatically added error.type so I think the fix for this is to remove error.type line above from our code.

I've opened #45218 to allow users to opt-out of our MDC logging and also relocate the JSON (for example to move it under the labels field as suggested in #45063 (comment)).

Unfortunately our existing logging.structured.json.rename.* property can't be used with the ECS nested format.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Relationships

None yet

    Development

    No branches or pull requests

      Participants

      @snicoll@philwebb@rafaelma@wilkinsona@kajh

      Issue actions

        ECS structure logging is not compatible with all collectors as it does not use the nested format · Issue #45063 · spring-projects/spring-boot