diff --git a/lapis2-docs/astro.config.mjs b/lapis2-docs/astro.config.mjs index 7e195453..97971f25 100644 --- a/lapis2-docs/astro.config.mjs +++ b/lapis2-docs/astro.config.mjs @@ -97,6 +97,10 @@ export default defineConfig({ link: '/concepts/variant-query/', onlyIfFeature: 'sarsCoV2VariantQuery', }, + { + label: 'Request Id', + link: '/concepts/request-id/', + }, ]), }, { diff --git a/lapis2-docs/src/content/docs/concepts/request-id.mdx b/lapis2-docs/src/content/docs/concepts/request-id.mdx new file mode 100644 index 00000000..cbcedb6c --- /dev/null +++ b/lapis2-docs/src/content/docs/concepts/request-id.mdx @@ -0,0 +1,23 @@ +--- +title: Request Id +description: Request Id +--- + +LAPIS uses a request id to identify requests. +This is useful for debugging and logging purposes. + +:::note +If you report an error that occurred in LAPIS, please include the request id. +This will greatly simplify identifying the problem in our log files. +::: + +You can provide a request id in the request as an HTTP header `X-Request-Id`. +This is useful if you want to correlate requests in your own log files with the LAPIS log files +to track problems across systems. + +:::caution +If you use the `X-Request-Id` header, make sure that the value is unique. +::: + +If you don't specify a request id, LAPIS will generate one for you. +It is contained in the response header `X-Request-Id` and in `info` in the response body. diff --git a/lapis2-docs/tests/docs.spec.ts b/lapis2-docs/tests/docs.spec.ts index 2243cd9e..2b6b356a 100644 --- a/lapis2-docs/tests/docs.spec.ts +++ b/lapis2-docs/tests/docs.spec.ts @@ -21,6 +21,7 @@ const conceptsPages = [ 'Request methods: GET and POST', 'Response format', 'Variant query', + 'Request Id', ]; const userTutorialPages = ['Plot the global distribution of all sequences in R']; diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt index 6c18683e..efa8aea1 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt @@ -1,6 +1,10 @@ package org.genspectrum.lapis import com.fasterxml.jackson.module.kotlin.readValue +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.oas.models.parameters.HeaderParameter import mu.KotlinLogging import org.genspectrum.lapis.auth.DataOpennessAuthorizationFilterFactory import org.genspectrum.lapis.config.DatabaseConfig @@ -16,9 +20,12 @@ import org.genspectrum.lapis.config.SequenceFilterFields import org.genspectrum.lapis.logging.RequestContext import org.genspectrum.lapis.logging.RequestContextLogger import org.genspectrum.lapis.logging.StatisticsLogObjectMapper +import org.genspectrum.lapis.openApi.REQUEST_ID_HEADER +import org.genspectrum.lapis.openApi.REQUEST_ID_HEADER_DESCRIPTION import org.genspectrum.lapis.openApi.buildOpenApiSchema import org.genspectrum.lapis.util.TimeFactory import org.genspectrum.lapis.util.YamlObjectMapper +import org.springdoc.core.customizers.OperationCustomizer import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -34,6 +41,23 @@ class LapisSpringConfig { referenceGenomeSchema: ReferenceGenomeSchema, ) = buildOpenApiSchema(sequenceFilterFields, databaseConfig, referenceGenomeSchema) + @Bean + fun headerCustomizer() = + OperationCustomizer { operation, _ -> + val foundRequestIdHeaderParameter = operation.parameters?.any { it.name == REQUEST_ID_HEADER } + if (foundRequestIdHeaderParameter == false || foundRequestIdHeaderParameter == null) { + operation.addParametersItem( + HeaderParameter().apply { + name = REQUEST_ID_HEADER + required = false + description = REQUEST_ID_HEADER_DESCRIPTION + content = Content().addMediaType("text/plain", MediaType().schema(Schema())) + }, + ) + } + operation + } + @Bean fun databaseConfig( @Value("\${lapis.databaseConfig.path}") configPath: String, diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/logging/RequestId.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/logging/RequestId.kt new file mode 100644 index 00000000..c2e619dc --- /dev/null +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/logging/RequestId.kt @@ -0,0 +1,43 @@ +package org.genspectrum.lapis.logging + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.openApi.REQUEST_ID_HEADER +import org.slf4j.MDC +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.context.annotation.RequestScope +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +@RequestScope +class RequestIdContext { + var requestId: String? = null +} + +private const val REQUEST_ID_MDC_KEY = "RequestId" +private const val HIGH_PRECEDENCE_BUT_LOW_ENOUGH_TO_HAVE_REQUEST_SCOPE_AVAILABLE = -100 + +@Component +@Order(HIGH_PRECEDENCE_BUT_LOW_ENOUGH_TO_HAVE_REQUEST_SCOPE_AVAILABLE) +class RequestIdFilter(private val requestIdContext: RequestIdContext) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val requestId = request.getHeader(REQUEST_ID_HEADER) ?: UUID.randomUUID().toString() + + MDC.put(REQUEST_ID_MDC_KEY, requestId) + requestIdContext.requestId = requestId + response.addHeader(REQUEST_ID_HEADER, requestId) + + try { + filterChain.doFilter(request, response) + } finally { + MDC.remove(REQUEST_ID_MDC_KEY) + } + } +} diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index c49237ac..40ceb8a9 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -255,12 +255,14 @@ private fun computePrimitiveFieldFilters( } private fun lapisResponseSchema(dataSchema: Schema) = - Schema().type("object").properties( - mapOf( - "data" to Schema().type("array").items(dataSchema), - "info" to infoResponseSchema(), - ), - ).required(listOf("data", "info")) + Schema().type("object") + .properties( + mapOf( + "data" to Schema().type("array").items(dataSchema), + "info" to infoResponseSchema(), + ), + ) + .required(listOf("data", "info")) private fun infoResponseSchema() = Schema().type("object") @@ -270,8 +272,10 @@ private fun infoResponseSchema() = "dataVersion" to Schema().type("string") .description(LAPIS_DATA_VERSION_RESPONSE_DESCRIPTION) .example(LAPIS_DATA_VERSION_EXAMPLE), + "requestId" to Schema().type("string").description(REQUEST_ID_HEADER_DESCRIPTION), ), - ).required(listOf("dataVersion")) + ) + .required(listOf("dataVersion")) private fun metadataFieldSchemas(databaseConfig: DatabaseConfig) = databaseConfig.schema.metadata.associate { it.name to Schema().type(mapToOpenApiType(it.type)) } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt index c546c50c..782975d0 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt @@ -69,6 +69,12 @@ const val LAPIS_DATA_VERSION_HEADER_DESCRIPTION = "$LAPIS_DATA_VERSION_DESCRIPTI const val LAPIS_DATA_VERSION_RESPONSE_DESCRIPTION = "$LAPIS_DATA_VERSION_DESCRIPTION " + "Same as the value returned in the info object in the header '$LAPIS_DATA_VERSION_HEADER'." +const val REQUEST_ID_HEADER = "X-Request-ID" +const val REQUEST_ID_HEADER_DESCRIPTION = """ +A UUID that uniquely identifies the request for tracing purposes. +If none if provided in the request, LAPIS will generate one. +""" + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Operation @@ -81,6 +87,11 @@ const val LAPIS_DATA_VERSION_RESPONSE_DESCRIPTION = "$LAPIS_DATA_VERSION_DESCRIP description = LAPIS_DATA_VERSION_HEADER_DESCRIPTION, schema = Schema(type = "string"), ), + Header( + name = REQUEST_ID_HEADER, + description = REQUEST_ID_HEADER_DESCRIPTION, + schema = Schema(type = "string"), + ), ], ) annotation class LapisResponseAnnotation( diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisInfo.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisInfo.kt index 863f0cc3..060a6c14 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisInfo.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisInfo.kt @@ -3,9 +3,11 @@ package org.genspectrum.lapis.request import io.swagger.v3.oas.annotations.media.Schema import org.genspectrum.lapis.controller.LapisErrorResponse import org.genspectrum.lapis.controller.LapisResponse +import org.genspectrum.lapis.logging.RequestIdContext import org.genspectrum.lapis.openApi.LAPIS_DATA_VERSION_EXAMPLE import org.genspectrum.lapis.openApi.LAPIS_DATA_VERSION_RESPONSE_DESCRIPTION import org.genspectrum.lapis.openApi.LAPIS_INFO_DESCRIPTION +import org.genspectrum.lapis.openApi.REQUEST_ID_HEADER_DESCRIPTION import org.genspectrum.lapis.silo.DataVersion import org.springframework.core.MethodParameter import org.springframework.http.MediaType @@ -21,12 +23,17 @@ data class LapisInfo( description = LAPIS_DATA_VERSION_RESPONSE_DESCRIPTION, example = LAPIS_DATA_VERSION_EXAMPLE, ) var dataVersion: String? = null, + @Schema(description = REQUEST_ID_HEADER_DESCRIPTION) + var requestId: String? = null, ) const val LAPIS_DATA_VERSION_HEADER = "Lapis-Data-Version" @ControllerAdvice -class ResponseBodyAdviceDataVersion(private val dataVersion: DataVersion) : ResponseBodyAdvice { +class ResponseBodyAdviceDataVersion( + private val dataVersion: DataVersion, + private val requestIdContext: RequestIdContext, +) : ResponseBodyAdvice { override fun beforeBodyWrite( body: Any?, returnType: MethodParameter, @@ -38,13 +45,15 @@ class ResponseBodyAdviceDataVersion(private val dataVersion: DataVersion) : Resp response.headers.add(LAPIS_DATA_VERSION_HEADER, dataVersion.dataVersion) when (body) { - is LapisResponse<*> -> return LapisResponse(body.data, LapisInfo(dataVersion.dataVersion)) - is LapisErrorResponse -> return LapisErrorResponse(body.error, LapisInfo(dataVersion.dataVersion)) + is LapisResponse<*> -> return LapisResponse(body.data, getLapisInfo()) + is LapisErrorResponse -> return LapisErrorResponse(body.error, getLapisInfo()) } return body } + private fun getLapisInfo() = LapisInfo(dataVersion.dataVersion, requestIdContext.requestId) + override fun supports( returnType: MethodParameter, converterType: Class>, diff --git a/lapis2/src/main/resources/logback.xml b/lapis2/src/main/resources/logback.xml index 8cfead90..da11f4ae 100644 --- a/lapis2/src/main/resources/logback.xml +++ b/lapis2/src/main/resources/logback.xml @@ -1,6 +1,6 @@ - + diff --git a/lapis2/src/test/resources/logback-test.xml b/lapis2/src/test/resources/logback-test.xml index 8bbb64b0..1ddb2cc7 100644 --- a/lapis2/src/test/resources/logback-test.xml +++ b/lapis2/src/test/resources/logback-test.xml @@ -1,7 +1,7 @@ - %date %level [%thread] %class: %message%n + %date %level [%thread] [%X{RequestId}] %class: %message%n diff --git a/siloLapisTests/test/requestId.spec.ts b/siloLapisTests/test/requestId.spec.ts new file mode 100644 index 00000000..65fef109 --- /dev/null +++ b/siloLapisTests/test/requestId.spec.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import { lapisClient } from './common'; + +describe('The request id', () => { + it('should be returned when explicitly specified', async () => { + const requestID = 'hardcodedRequestIdInTheTest'; + + const result = await lapisClient.postAggregated1({ + aggregatedPostRequest: {}, + xRequestID: requestID, + }); + + expect(result.info.requestId).equals(requestID); + }); + + it('should be generated when none is specified', async () => { + const result = await lapisClient.postAggregated1({ + aggregatedPostRequest: {}, + }); + + expect(result.info.requestId).length.is.at.least(1); + }); +});