diff --git a/API.md b/API.md index 5adeb22..c291141 100644 --- a/API.md +++ b/API.md @@ -29,6 +29,16 @@ Error response: } ``` +### Response Headers + +Every response may include correlation header: + +```http +X-Trace-Id: +``` + +Use this value to correlate client-side errors with server logs/traces (including `401`, `403`, and `429` responses). + ### Error Codes | Code | Meaning | @@ -507,3 +517,11 @@ Response: When rate limit is exceeded, the API returns HTTP `429 Too Many Requests`. +Typical response headers for a throttled request include `X-Trace-Id`, which can be used for diagnostics: + +```http +HTTP/1.1 429 Too Many Requests +Content-Type: application/json +X-Trace-Id: 2f0a3e58a2d7f97c3f6d9d6cc2b1aa93 +``` + diff --git a/CHANGELOG.md b/CHANGELOG.md index fd27d47..418b47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Account endpoints (`GET /api/accounts/client/{clientId}`, `POST /api/accounts/balance/pessimistic`, `POST /api/accounts/balance/optimistic`) - Authentication endpoints (login, refresh, logout) - Rate limiting with Bucket4j (login: 5 req/min, clients: 20 req/min) +- HTTP response header `X-Trace-Id` for request/trace correlation, including error responses (`401`, `403`, `429`) - Internationalization (i18n) — English and Russian - Flyway database migrations - OpenTelemetry tracing with OTLP exporter diff --git a/README.md b/README.md index bd6d592..fbd85e8 100644 --- a/README.md +++ b/README.md @@ -738,6 +738,15 @@ For DPoP-bound access tokens: - send `DPoP` proof for every protected request - ensure proof matches method+URL and is signed by key matching `cnf.jkt` +If request fails and the response includes `X-Trace-Id`, use it to find correlated logs/traces. + +--- + +### 🔎 Correlating errors with logs/traces +The API may include `X-Trace-Id` response header for request correlation when a trace id is available. + +Use this header value, when present, when investigating `401`, `403`, and `429` responses in Grafana/Tempo/Loki. + --- ### ❌ Logout always returns 200 diff --git a/src/main/java/lt/satsyuk/config/SecurityConfig.java b/src/main/java/lt/satsyuk/config/SecurityConfig.java index fa65afa..f7a38e0 100644 --- a/src/main/java/lt/satsyuk/config/SecurityConfig.java +++ b/src/main/java/lt/satsyuk/config/SecurityConfig.java @@ -6,6 +6,7 @@ import lt.satsyuk.security.KeycloakOpaqueRoleConverter; import lt.satsyuk.security.KeycloakOpaqueTokenIntrospector; import lt.satsyuk.security.RateLimitingFilter; +import lt.satsyuk.security.TraceIdResponseHeaderFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -28,6 +29,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, OpaqueTokenIntrospector opaqueTokenIntrospector, JsonAuthEntryPoint jsonAuthEntryPoint, JsonAccessDeniedHandler jsonAccessDeniedHandler, + TraceIdResponseHeaderFilter traceIdResponseHeaderFilter, DpopAuthenticationFilter dpopAuthenticationFilter, RateLimitingFilter rateLimitingFilter) { @@ -53,6 +55,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .accessDeniedHandler(jsonAccessDeniedHandler) ); + http.addFilterBefore(traceIdResponseHeaderFilter, AuthenticationFilter.class); http.addFilterAfter(dpopAuthenticationFilter, AuthenticationFilter.class); http.addFilterAfter(rateLimitingFilter, DpopAuthenticationFilter.class); diff --git a/src/main/java/lt/satsyuk/security/TraceIdResponseHeaderFilter.java b/src/main/java/lt/satsyuk/security/TraceIdResponseHeaderFilter.java new file mode 100644 index 0000000..fa63397 --- /dev/null +++ b/src/main/java/lt/satsyuk/security/TraceIdResponseHeaderFilter.java @@ -0,0 +1,60 @@ +package lt.satsyuk.security; + +import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.Tracer; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class TraceIdResponseHeaderFilter extends OncePerRequestFilter { + + public static final String TRACE_ID_HEADER = "X-Trace-Id"; + private static final String MDC_TRACE_ID_KEY = "traceId"; + + private final Tracer tracer; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String traceId = resolveTraceId(); + if (StringUtils.hasText(traceId)) { + response.setHeader(TRACE_ID_HEADER, traceId); + } + + filterChain.doFilter(request, response); + + if (!response.isCommitted() && !response.containsHeader(TRACE_ID_HEADER)) { + String lateTraceId = resolveTraceId(); + if (StringUtils.hasText(lateTraceId)) { + response.setHeader(TRACE_ID_HEADER, lateTraceId); + } + } + } + + private String resolveTraceId() { + Span span = tracer.currentSpan(); + if (span != null) { + TraceContext context = span.context(); + if (StringUtils.hasText(context.traceId())) { + return context.traceId(); + } + } + + String traceIdFromMdc = MDC.get(MDC_TRACE_ID_KEY); + return StringUtils.hasText(traceIdFromMdc) ? traceIdFromMdc : null; + } +} + + diff --git a/src/test/java/lt/satsyuk/security/TraceIdResponseHeaderFilterTest.java b/src/test/java/lt/satsyuk/security/TraceIdResponseHeaderFilterTest.java new file mode 100644 index 0000000..ccc0801 --- /dev/null +++ b/src/test/java/lt/satsyuk/security/TraceIdResponseHeaderFilterTest.java @@ -0,0 +1,90 @@ +package lt.satsyuk.security; + +import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TraceIdResponseHeaderFilterTest { + + @Mock + private Tracer tracer; + + @Mock + private Span span; + + @Mock + private TraceContext traceContext; + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + void addsHeaderFromCurrentSpanTraceId() throws Exception { + when(tracer.currentSpan()).thenReturn(span); + when(span.context()).thenReturn(traceContext); + when(traceContext.traceId()).thenReturn("trace-from-span"); + + TraceIdResponseHeaderFilter filter = new TraceIdResponseHeaderFilter(tracer); + MockHttpServletResponse response = doFilter(filter); + + assertThat(response.getHeader(TraceIdResponseHeaderFilter.TRACE_ID_HEADER)).isEqualTo("trace-from-span"); + } + + @Test + void fallsBackToMdcWhenCurrentSpanMissing() throws Exception { + when(tracer.currentSpan()).thenReturn(null); + MDC.put("traceId", "trace-from-mdc"); + + TraceIdResponseHeaderFilter filter = new TraceIdResponseHeaderFilter(tracer); + MockHttpServletResponse response = doFilter(filter); + + assertThat(response.getHeader(TraceIdResponseHeaderFilter.TRACE_ID_HEADER)).isEqualTo("trace-from-mdc"); + } + + @Test + void doesNotAddHeaderWhenTraceIdUnavailable() throws Exception { + when(tracer.currentSpan()).thenReturn(null); + + TraceIdResponseHeaderFilter filter = new TraceIdResponseHeaderFilter(tracer); + MockHttpServletResponse response = doFilter(filter); + + assertThat(response.getHeader(TraceIdResponseHeaderFilter.TRACE_ID_HEADER)).isNull(); + } + + @Test + void addsHeaderWhenTraceIdAppearsAfterFilterChain() throws Exception { + when(tracer.currentSpan()).thenReturn(null); + + TraceIdResponseHeaderFilter filter = new TraceIdResponseHeaderFilter(tracer); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/clients"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> MDC.put("traceId", "late-trace-id")); + + assertThat(response.getHeader(TraceIdResponseHeaderFilter.TRACE_ID_HEADER)).isEqualTo("late-trace-id"); + } + + private MockHttpServletResponse doFilter(TraceIdResponseHeaderFilter filter) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/clients"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + return response; + } +} +