Adds minimal traceparent header support to Elasticsearch (#74210)

This adds just enough support for the traceparent header to be useful in es8.
Since Elasticsearch already logs in ECS format extending it with support for transaction.id and trace.id is a quick win.
This allows us to surface server/deprecation slow logs from an instrumented application using the Trace Logs feature.
Parsing `traceparent` in http layer and populating tasks with `trace_id` which is preserved in thread context.
This commit is contained in:
Martijn Laarman 2021-06-21 16:27:59 +02:00 committed by GitHub
parent 40b1dc0d3c
commit c412aaedee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 168 additions and 27 deletions

View file

@ -115,6 +115,7 @@ public class JsonLoggerTests extends ESTestCase {
public void testCompatibleLog() throws Exception { public void testCompatibleLog() throws Exception {
withThreadContext(threadContext -> { withThreadContext(threadContext -> {
threadContext.putHeader(Task.X_OPAQUE_ID, "someId"); threadContext.putHeader(Task.X_OPAQUE_ID, "someId");
threadContext.putHeader(Task.TRACE_ID, "someTraceId");
final DeprecationLogger testLogger = DeprecationLogger.getLogger("org.elasticsearch.test"); final DeprecationLogger testLogger = DeprecationLogger.getLogger("org.elasticsearch.test");
testLogger.deprecate(DeprecationCategory.OTHER,"someKey", "deprecated message1") testLogger.deprecate(DeprecationCategory.OTHER,"someKey", "deprecated message1")
.compatibleApiWarning("compatibleKey","compatible API message"); .compatibleApiWarning("compatibleKey","compatible API message");
@ -143,6 +144,7 @@ public class JsonLoggerTests extends ESTestCase {
hasEntry("message", "deprecated message1"), hasEntry("message", "deprecated message1"),
hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "someKey"), hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "someKey"),
hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"), hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"),
hasEntry(Task.TRACE_ID, "someTraceId"),
hasEntry("elasticsearch.event.category", "other") hasEntry("elasticsearch.event.category", "other")
), ),
allOf( allOf(
@ -159,6 +161,7 @@ public class JsonLoggerTests extends ESTestCase {
hasEntry("message", "compatible API message"), hasEntry("message", "compatible API message"),
hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "compatibleKey"), hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "compatibleKey"),
hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"), hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"),
hasEntry(Task.TRACE_ID, "someTraceId"),
hasEntry("elasticsearch.event.category", "compatible_api") hasEntry("elasticsearch.event.category", "compatible_api")
) )
) )
@ -172,6 +175,7 @@ public class JsonLoggerTests extends ESTestCase {
public void testParseFieldEmittingDeprecatedLogs() throws Exception { public void testParseFieldEmittingDeprecatedLogs() throws Exception {
withThreadContext(threadContext -> { withThreadContext(threadContext -> {
threadContext.putHeader(Task.X_OPAQUE_ID, "someId"); threadContext.putHeader(Task.X_OPAQUE_ID, "someId");
threadContext.putHeader(Task.TRACE_ID, "someTraceId");
ParseField deprecatedField = new ParseField("new_name", "deprecated_name"); ParseField deprecatedField = new ParseField("new_name", "deprecated_name");
assertTrue(deprecatedField.match("deprecated_name", LoggingDeprecationHandler.INSTANCE)); assertTrue(deprecatedField.match("deprecated_name", LoggingDeprecationHandler.INSTANCE));
@ -208,6 +212,7 @@ public class JsonLoggerTests extends ESTestCase {
hasEntry("message", "Deprecated field [deprecated_name] used, expected [new_name] instead"), hasEntry("message", "Deprecated field [deprecated_name] used, expected [new_name] instead"),
hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_deprecated_name"), hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_deprecated_name"),
hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"), hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"),
hasEntry(Task.TRACE_ID, "someTraceId"),
hasEntry("elasticsearch.event.category", "api") hasEntry("elasticsearch.event.category", "api")
), ),
// deprecation log for field deprecated_name2 (note it is not being throttled) // deprecation log for field deprecated_name2 (note it is not being throttled)
@ -224,6 +229,7 @@ public class JsonLoggerTests extends ESTestCase {
hasEntry("message", "Deprecated field [deprecated_name2] used, expected [new_name] instead"), hasEntry("message", "Deprecated field [deprecated_name2] used, expected [new_name] instead"),
hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_deprecated_name2"), hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_deprecated_name2"),
hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"), hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"),
hasEntry(Task.TRACE_ID, "someTraceId"),
hasEntry("elasticsearch.event.category", "api") hasEntry("elasticsearch.event.category", "api")
), ),
// compatible log line // compatible log line
@ -240,6 +246,7 @@ public class JsonLoggerTests extends ESTestCase {
hasEntry("message", "Deprecated field [compatible_deprecated_name] used, expected [new_name] instead"), hasEntry("message", "Deprecated field [compatible_deprecated_name] used, expected [new_name] instead"),
hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_compatible_deprecated_name"), hasEntry(DeprecatedMessage.KEY_FIELD_NAME, "deprecated_field_compatible_deprecated_name"),
hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"), hasEntry(DeprecatedMessage.X_OPAQUE_ID_FIELD_NAME, "someId"),
hasEntry(Task.TRACE_ID, "someTraceId"),
hasEntry("elasticsearch.event.category", "compatible_api") hasEntry("elasticsearch.event.category", "compatible_api")
) )
) )
@ -255,6 +262,7 @@ public class JsonLoggerTests extends ESTestCase {
public void testDeprecatedMessage() throws Exception { public void testDeprecatedMessage() throws Exception {
withThreadContext(threadContext -> { withThreadContext(threadContext -> {
threadContext.putHeader(Task.X_OPAQUE_ID, "someId"); threadContext.putHeader(Task.X_OPAQUE_ID, "someId");
threadContext.putHeader(Task.TRACE_ID, "someTraceId");
final DeprecationLogger testLogger = DeprecationLogger.getLogger("org.elasticsearch.test"); final DeprecationLogger testLogger = DeprecationLogger.getLogger("org.elasticsearch.test");
testLogger.deprecate(DeprecationCategory.OTHER, "someKey", "deprecated message1"); testLogger.deprecate(DeprecationCategory.OTHER, "someKey", "deprecated message1");

View file

@ -434,7 +434,10 @@ public class ActionModule extends AbstractModule {
destructiveOperations = new DestructiveOperations(settings, clusterSettings); destructiveOperations = new DestructiveOperations(settings, clusterSettings);
Set<RestHeaderDefinition> headers = Stream.concat( Set<RestHeaderDefinition> headers = Stream.concat(
actionPlugins.stream().flatMap(p -> p.getRestHeaders().stream()), actionPlugins.stream().flatMap(p -> p.getRestHeaders().stream()),
Stream.of(new RestHeaderDefinition(Task.X_OPAQUE_ID, false)) Stream.of(
new RestHeaderDefinition(Task.X_OPAQUE_ID, false),
new RestHeaderDefinition(Task.TRACE_PARENT, false)
)
).collect(Collectors.toSet()); ).collect(Collectors.toSet());
UnaryOperator<RestHandler> restWrapper = null; UnaryOperator<RestHandler> restWrapper = null;
for (ActionPlugin plugin : actionPlugins) { for (ActionPlugin plugin : actionPlugins) {

View file

@ -55,6 +55,7 @@ public class ECSJsonLayout {
private KeyValuePair[] additionalFields() { private KeyValuePair[] additionalFields() {
return new KeyValuePair[] { return new KeyValuePair[] {
new KeyValuePair("event.dataset", dataset), new KeyValuePair("event.dataset", dataset),
new KeyValuePair("trace.id", "%trace_id"),
new KeyValuePair("elasticsearch.cluster.uuid", "%cluster_id"), new KeyValuePair("elasticsearch.cluster.uuid", "%cluster_id"),
new KeyValuePair("elasticsearch.node.id", "%node_id"), new KeyValuePair("elasticsearch.node.id", "%node_id"),
new KeyValuePair("elasticsearch.node.name", "%ESnode_name"), new KeyValuePair("elasticsearch.node.name", "%ESnode_name"),

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
package org.elasticsearch.common.logging;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.pattern.ConverterKeys;
import org.apache.logging.log4j.core.pattern.LogEventPatternConverter;
import org.apache.logging.log4j.core.pattern.PatternConverter;
import org.elasticsearch.tasks.Task;
import java.util.Objects;
/**
* Pattern converter to format the trace id provided in the traceparent header into JSON fields <code>trace.id</code>.
*/
@Plugin(category = PatternConverter.CATEGORY, name = "TraceIdConverter")
@ConverterKeys({"trace_id"})
public final class TraceIdConverter extends LogEventPatternConverter {
/**
* Called by log4j2 to initialize this converter.
*/
public static TraceIdConverter newInstance(@SuppressWarnings("unused") final String[] options) {
return new TraceIdConverter();
}
public TraceIdConverter() {
super("trace_id", "trace_id");
}
public static String getTraceId() {
return HeaderWarning.THREAD_CONTEXT.stream()
.map(t -> t.<String>getHeader(Task.TRACE_ID))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
/**
* Formats the trace.id into json fields.
*
* @param event - a log event is ignored in this method as it uses the clusterId value
* from <code>NodeAndClusterIdStateListener</code> to format
*/
@Override
public void format(LogEvent event, StringBuilder toAppendTo) {
String traceId = getTraceId();
if (traceId != null) {
toAppendTo.append(traceId);
}
}
}

View file

@ -107,13 +107,22 @@ public final class ThreadContext implements Writeable {
/** /**
* X-Opaque-ID should be preserved in a threadContext in order to propagate this across threads. * X-Opaque-ID should be preserved in a threadContext in order to propagate this across threads.
* This is needed so the DeprecationLogger in another thread can see the value of X-Opaque-ID provided by a user. * This is needed so the DeprecationLogger in another thread can see the value of X-Opaque-ID provided by a user.
* The same is applied to Task.TRACE_ID.
* Otherwise when context is stash, it should be empty. * Otherwise when context is stash, it should be empty.
*/ */
if (context.requestHeaders.containsKey(Task.X_OPAQUE_ID) || context.requestHeaders.containsKey(Task.TRACE_ID)) {
Map<String, String> map = new HashMap<>(2, 1);
if (context.requestHeaders.containsKey(Task.X_OPAQUE_ID)) { if (context.requestHeaders.containsKey(Task.X_OPAQUE_ID)) {
ThreadContextStruct threadContextStruct = map.put(Task.X_OPAQUE_ID, context.requestHeaders.get(Task.X_OPAQUE_ID));
DEFAULT_CONTEXT.putHeaders(Map.of(Task.X_OPAQUE_ID, context.requestHeaders.get(Task.X_OPAQUE_ID))); }
if (context.requestHeaders.containsKey(Task.TRACE_ID)) {
map.put(Task.TRACE_ID, context.requestHeaders.get(Task.TRACE_ID));
}
ThreadContextStruct threadContextStruct = DEFAULT_CONTEXT.putHeaders(map);
threadLocal.set(threadContextStruct); threadLocal.set(threadContextStruct);
} else { }
else {
threadLocal.set(DEFAULT_CONTEXT); threadLocal.set(DEFAULT_CONTEXT);
} }
return () -> { return () -> {

View file

@ -574,7 +574,7 @@ public class Node implements Closeable {
final Transport transport = networkModule.getTransportSupplier().get(); final Transport transport = networkModule.getTransportSupplier().get();
Set<String> taskHeaders = Stream.concat( Set<String> taskHeaders = Stream.concat(
pluginsService.filterPlugins(ActionPlugin.class).stream().flatMap(p -> p.getTaskHeaders().stream()), pluginsService.filterPlugins(ActionPlugin.class).stream().flatMap(p -> p.getTaskHeaders().stream()),
Stream.of(Task.X_OPAQUE_ID) Stream.of(Task.X_OPAQUE_ID, Task.TRACE_ID)
).collect(Collectors.toSet()); ).collect(Collectors.toSet());
final TransportService transportService = newTransportService(settings, transport, threadPool, final TransportService transportService = newTransportService(settings, transport, threadPool,
networkModule.getTransportInterceptor(), localNodeFactory, settingsModule.getClusterSettings(), taskHeaders); networkModule.getTransportInterceptor(), localNodeFactory, settingsModule.getClusterSettings(), taskHeaders);

View file

@ -29,6 +29,7 @@ import org.elasticsearch.core.internal.io.Streams;
import org.elasticsearch.http.HttpServerTransport; import org.elasticsearch.http.HttpServerTransport;
import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.indices.breaker.CircuitBreakerService;
import org.elasticsearch.rest.RestHandler.Route; import org.elasticsearch.rest.RestHandler.Route;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.usage.UsageService; import org.elasticsearch.usage.UsageService;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@ -334,29 +335,15 @@ public class RestController implements HttpServerTransport.Dispatcher {
} }
private void tryAllHandlers(final RestRequest request, final RestChannel channel, final ThreadContext threadContext) throws Exception { private void tryAllHandlers(final RestRequest request, final RestChannel channel, final ThreadContext threadContext) throws Exception {
for (final RestHeaderDefinition restHeader : headersToCopy) { try {
final String name = restHeader.getName(); copyRestHeaders(request, threadContext);
final List<String> headerValues = request.getAllHeaderValues(name); validateErrorTrace(request, channel);
if (headerValues != null && headerValues.isEmpty() == false) { } catch (IllegalArgumentException e) {
final List<String> distinctHeaderValues = headerValues.stream().distinct().collect(Collectors.toList()); channel.sendResponse(BytesRestResponse.createSimpleErrorResponse(channel, BAD_REQUEST, e.getMessage()));
if (restHeader.isMultiValueAllowed() == false && distinctHeaderValues.size() > 1) {
channel.sendResponse(
BytesRestResponse.
createSimpleErrorResponse(channel, BAD_REQUEST, "multiple values for single-valued header [" + name + "]."));
return;
} else {
threadContext.putHeader(name, String.join(",", distinctHeaderValues));
}
}
}
// error_trace cannot be used when we disable detailed errors
// we consume the error_trace parameter first to ensure that it is always consumed
if (request.paramAsBoolean("error_trace", false) && channel.detailedErrorsEnabled() == false) {
channel.sendResponse(
BytesRestResponse.createSimpleErrorResponse(channel, BAD_REQUEST, "error traces in responses are disabled."));
return; return;
} }
final String rawPath = request.rawPath(); final String rawPath = request.rawPath();
final String uri = request.uri(); final String uri = request.uri();
final RestRequest.Method requestMethod; final RestRequest.Method requestMethod;
@ -392,6 +379,34 @@ public class RestController implements HttpServerTransport.Dispatcher {
handleBadRequest(uri, requestMethod, channel); handleBadRequest(uri, requestMethod, channel);
} }
private void validateErrorTrace(RestRequest request, RestChannel channel) {
// error_trace cannot be used when we disable detailed errors
// we consume the error_trace parameter first to ensure that it is always consumed
if (request.paramAsBoolean("error_trace", false) && channel.detailedErrorsEnabled() == false) {
throw new IllegalArgumentException("error traces in responses are disabled.");
}
}
private void copyRestHeaders(RestRequest request, ThreadContext threadContext) throws IOException {
for (final RestHeaderDefinition restHeader : headersToCopy) {
final String name = restHeader.getName();
final List<String> headerValues = request.getAllHeaderValues(name);
if (headerValues != null && headerValues.isEmpty() == false) {
final List<String> distinctHeaderValues = headerValues.stream().distinct().collect(Collectors.toList());
if (restHeader.isMultiValueAllowed() == false && distinctHeaderValues.size() > 1) {
throw new IllegalArgumentException("multiple values for single-valued header [" + name + "].");
} else if (name.equals(Task.TRACE_PARENT)) {
String traceparent = distinctHeaderValues.get(0);
if (traceparent.length() >= 55) {
threadContext.putHeader(Task.TRACE_ID, traceparent.substring(3, 35));
}
} else {
threadContext.putHeader(name, String.join(",", distinctHeaderValues));
}
}
}
}
Iterator<MethodHandlers> getAllHandlers(@Nullable Map<String, String> requestParamsRef, String rawPath) { Iterator<MethodHandlers> getAllHandlers(@Nullable Map<String, String> requestParamsRef, String rawPath) {
final Supplier<Map<String, String>> paramsSupplier; final Supplier<Map<String, String>> paramsSupplier;
if (requestParamsRef == null) { if (requestParamsRef == null) {

View file

@ -28,6 +28,19 @@ public class Task {
*/ */
public static final String X_OPAQUE_ID = "X-Opaque-Id"; public static final String X_OPAQUE_ID = "X-Opaque-Id";
/**
* The request header which is contained in HTTP request. We parse trace.id from it and store it in thread context.
* TRACE_PARENT once parsed in RestController.tryAllHandler is not preserved
* has to be declared as a header copied over from http request.
*/
public static final String TRACE_PARENT = "traceparent";
/**
* Parsed part of traceparent. It is stored in thread context and emitted in logs.
* Has to be declared as a header copied over for tasks.
*/
public static final String TRACE_ID = "trace.id";
private final long id; private final long id;
private final String type; private final String type;

View file

@ -32,6 +32,7 @@ import org.elasticsearch.http.HttpServerTransport;
import org.elasticsearch.http.HttpStats; import org.elasticsearch.http.HttpStats;
import org.elasticsearch.indices.breaker.HierarchyCircuitBreakerService; import org.elasticsearch.indices.breaker.HierarchyCircuitBreakerService;
import org.elasticsearch.rest.RestHandler.Route; import org.elasticsearch.rest.RestHandler.Route;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.client.NoOpNodeClient; import org.elasticsearch.test.client.NoOpNodeClient;
import org.elasticsearch.test.rest.FakeRestRequest; import org.elasticsearch.test.rest.FakeRestRequest;
@ -163,6 +164,38 @@ public class RestControllerTests extends ESTestCase {
assertTrue(channel.getSendResponseCalled()); assertTrue(channel.getSendResponseCalled());
} }
public void testTraceParentAndTraceId() throws Exception {
final ThreadContext threadContext = client.threadPool().getThreadContext();
Set<RestHeaderDefinition> headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition(Task.TRACE_PARENT, false)));
final RestController restController = new RestController(headers, null, null, circuitBreakerService, usageService);
Map<String, List<String>> restHeaders = new HashMap<>();
restHeaders.put(Task.TRACE_PARENT, Collections.singletonList("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"));
RestRequest fakeRequest = new FakeRestRequest.Builder(xContentRegistry()).withHeaders(restHeaders).build();
final RestController spyRestController = spy(restController);
when(spyRestController.getAllHandlers(null, fakeRequest.rawPath()))
.thenReturn(new Iterator<>() {
@Override
public boolean hasNext() {
return false;
}
@Override
public MethodHandlers next() {
return new MethodHandlers("/")
.addMethod(GET, RestApiVersion.current(), (request, channel, client) -> {
assertEquals("0af7651916cd43dd8448eb211c80319c", threadContext.getHeader(Task.TRACE_ID));
assertNull(threadContext.getHeader(Task.TRACE_PARENT));
});
}
});
AssertingChannel channel = new AssertingChannel(fakeRequest, false, RestStatus.BAD_REQUEST);
restController.dispatchRequest(fakeRequest, channel, threadContext);
// the rest controller relies on the caller to stash the context, so we should expect these values here as we didn't stash the
// context in this test
assertEquals("0af7651916cd43dd8448eb211c80319c", threadContext.getHeader(Task.TRACE_ID));
assertNull(threadContext.getHeader(Task.TRACE_PARENT));
}
public void testRequestWithDisallowedMultiValuedHeaderButSameValues() { public void testRequestWithDisallowedMultiValuedHeaderButSameValues() {
final ThreadContext threadContext = client.threadPool().getThreadContext(); final ThreadContext threadContext = client.threadPool().getThreadContext();
Set<RestHeaderDefinition> headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition("header.1", true), Set<RestHeaderDefinition> headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition("header.1", true),