diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnostics.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnostics.java index 0d2220462..b94d9c9b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnostics.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnostics.java @@ -39,12 +39,16 @@ final class QueryDiagnostics { int statementDepth; MethodTiming timing(String method) { return methods.computeIfAbsent(method, MethodTiming::new); } String json(String endpoint, String requestId, int status, boolean failed) { + return json(endpoint, requestId, status, failed, "REQUEST_COMPLETE"); + } + String json(String endpoint, String requestId, int status, boolean failed, String capturePoint) { long total = System.nanoTime() - started; long executor = methods.values().stream().mapToLong(t -> t.executor.nanos).sum(); Map report = new LinkedHashMap<>(); report.put("event", "SELFOP_QUERY_DIAGNOSTICS"); report.put("endpoint", endpoint); report.put("requestId", requestId != null && requestId.matches("[A-Za-z0-9._:-]{1,128}") ? requestId : null); report.put("httpStatus", status); report.put("escapedException", failed); + report.put("capturePoint", capturePoint); report.put("requestNanos", total); report.put("unattributedNanos", Math.max(0, total-executor)); report.put("timingMeaning", "CLIENT_EXECUTOR_AND_STATEMENT_INCLUDING_MAPPING_NOT_SERVER_DB_TIME"); report.put("poolWaitAttribution", "NOT_MEASURED_PER_REQUEST"); report.put("methods", methods); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsResponseAdvice.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsResponseAdvice.java new file mode 100644 index 000000000..e02af0926 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsResponseAdvice.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.selfoperated.analytics.query.diagnostics; + +import com.baiyue.common.web.CorrelationContext; +import org.slf4j.MDC; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.*; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import java.util.Map; + +/** Opt-in diagnostic snapshot before serialization; never changes the response body. */ +@RestControllerAdvice +@ConditionalOnExpression("'${DEPLOY_ENV:}' == 'test' && ${baiyue.self-operated-analytics.query-diagnostics.enabled:false}") +public final class QueryDiagnosticsResponseAdvice implements ResponseBodyAdvice { + public static final String HEADER = "X-Selfop-Query-Diagnostics"; + private static final String BASE = "/api/platform/v1/last-leg/self-operated-analytics/"; + private static final Map ENDPOINTS = Map.of(BASE+"dashboard", "DASHBOARD", + BASE+"sku-sales-summary", "SUMMARY", BASE+"inventory-coverage", "INVENTORY"); + @Override public boolean supports(MethodParameter returnType, Class> converterType) { + return QueryDiagnostics.CURRENT.get() != null; + } + @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType contentType, + Class> converterType, ServerHttpRequest request, ServerHttpResponse response) { + var state = QueryDiagnostics.CURRENT.get(); + String endpoint = ENDPOINTS.get(request.getURI().getPath()); + if (state != null && endpoint != null && request.getMethod() == org.springframework.http.HttpMethod.GET) { + int status = response instanceof ServletServerHttpResponse servlet ? servlet.getServletResponse().getStatus() : 200; + response.getHeaders().set(HEADER, state.json(endpoint, MDC.get(CorrelationContext.REQUEST_ID), status, false, "BEFORE_BODY_WRITE")); + } + return body; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsTest.java index 1e93fa2ca..8abdfe2c6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsTest.java @@ -25,15 +25,47 @@ class QueryDiagnosticsTest { @AfterEach void cleanup() { QueryDiagnostics.CURRENT.remove(); MDC.clear(); } @Test void onlyExplicitTestAndEnabledCreatesBeans() { - var runner=new ApplicationContextRunner().withUserConfiguration(QueryDiagnosticsConfiguration.class); - runner.run(c->assertEquals(0,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size())); + var runner=new ApplicationContextRunner().withUserConfiguration(QueryDiagnosticsConfiguration.class,QueryDiagnosticsResponseAdvice.class); + runner.run(c->{assertEquals(0,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size());assertEquals(0,c.getBeansOfType(QueryDiagnosticsResponseAdvice.class).size());}); runner.withPropertyValues("DEPLOY_ENV=test").run(c->assertEquals(0,c.getBeansOfType(FilterRegistrationBean.class).size())); runner.withPropertyValues("DEPLOY_ENV=production","baiyue.self-operated-analytics.query-diagnostics.enabled=true") - .run(c->assertEquals(0,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size())); + .run(c->{assertEquals(0,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size());assertEquals(0,c.getBeansOfType(QueryDiagnosticsResponseAdvice.class).size());}); runner.withPropertyValues("baiyue.self-operated-analytics.query-diagnostics.enabled=true") .run(c->assertEquals(0,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size())); runner.withPropertyValues("DEPLOY_ENV=test","baiyue.self-operated-analytics.query-diagnostics.enabled=true") - .run(c->{assertEquals(1,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size());assertEquals(1,c.getBeansOfType(FilterRegistrationBean.class).size());}); + .run(c->{assertEquals(1,c.getBeansOfType(QueryDiagnosticsInterceptor.class).size());assertEquals(1,c.getBeansOfType(FilterRegistrationBean.class).size());assertEquals(1,c.getBeansOfType(QueryDiagnosticsResponseAdvice.class).size());}); + } + + @Test void responseHeaderRequiresActiveStateAndPreservesBodyAndCapturePoint() throws Exception { + var advice=new QueryDiagnosticsResponseAdvice(); + Object body=Map.of("sku","PRIVATE_SKU","token","PRIVATE_TOKEN"); + var servletRequest=new MockHttpServletRequest("GET",BASE+"dashboard"); + servletRequest.setQueryString("sourceAccount=PRIVATE_ACCOUNT"); + var request=new org.springframework.http.server.ServletServerHttpRequest(servletRequest); + var response=new org.springframework.http.server.ServletServerHttpResponse(new MockHttpServletResponse()); + assertFalse(advice.supports(null,null)); + assertSame(body,advice.beforeBodyWrite(body,null,null,null,request,response)); + assertNull(response.getHeaders().getFirst(QueryDiagnosticsResponseAdvice.HEADER)); + var state=new QueryDiagnostics.State();QueryDiagnostics.CURRENT.set(state);MDC.put("requestId","synthetic-header-1"); + state.timing("DashboardReadMapper.report").executor.add(123,false); + assertTrue(advice.supports(null,null)); + assertSame(body,advice.beforeBodyWrite(body,null,null,null,request,response)); + String header=response.getHeaders().getFirst(QueryDiagnosticsResponseAdvice.HEADER); + assertNotNull(header);assertFalse(header.contains("PRIVATE"));assertFalse(header.contains("\n")); + var json=new ObjectMapper().readTree(header); + assertEquals("BEFORE_BODY_WRITE",json.path("capturePoint").asText()); + assertEquals("DASHBOARD",json.path("endpoint").asText()); + assertEquals("synthetic-header-1",json.path("requestId").asText()); + assertEquals(1,json.path("methods").path("DashboardReadMapper.report").path("executor").path("calls").asInt()); + assertEquals("REQUEST_COMPLETE",new ObjectMapper().readTree(state.json("DASHBOARD","synthetic",200,false)).path("capturePoint").asText()); + for(String path:List.of("other","dashboard/extra")){ + var skipped=new org.springframework.http.server.ServletServerHttpResponse(new MockHttpServletResponse()); + assertSame(body,advice.beforeBodyWrite(body,null,null,null,new org.springframework.http.server.ServletServerHttpRequest(new MockHttpServletRequest("GET",BASE+path)),skipped)); + assertNull(skipped.getHeaders().getFirst(QueryDiagnosticsResponseAdvice.HEADER)); + } + var post=new org.springframework.http.server.ServletServerHttpResponse(new MockHttpServletResponse()); + advice.beforeBodyWrite(body,null,null,null,new org.springframework.http.server.ServletServerHttpRequest(new MockHttpServletRequest("POST",BASE+"dashboard")),post); + assertNull(post.getHeaders().getFirst(QueryDiagnosticsResponseAdvice.HEADER)); } @Test void pathsAndMethodsAreExactAndExceptionsCleanRequestState() throws Exception {