Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ Error response:
}
```

### Response Headers

Every response may include correlation header:

```http
X-Trace-Id: <trace-id>
```

Use this value to correlate client-side errors with server logs/traces (including `401`, `403`, and `429` responses).

### Error Codes

| Code | Meaning |
Expand Down Expand Up @@ -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
```

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/lt/satsyuk/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
OpaqueTokenIntrospector opaqueTokenIntrospector,
JsonAuthEntryPoint jsonAuthEntryPoint,
JsonAccessDeniedHandler jsonAccessDeniedHandler,
TraceIdResponseHeaderFilter traceIdResponseHeaderFilter,
DpopAuthenticationFilter dpopAuthenticationFilter,
RateLimitingFilter rateLimitingFilter) {

Expand All @@ -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);

Expand Down
60 changes: 60 additions & 0 deletions src/main/java/lt/satsyuk/security/TraceIdResponseHeaderFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Comment on lines +36 to +43
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doFilterInternal tries to set the header after filterChain.doFilter(...), but by that point the response may already be committed (e.g., error handlers writing/flushing the body). In that case, setHeader will be ignored by the servlet container and the “lateTraceId” path won’t actually add the header. Consider guarding the post-chain header write with !response.isCommitted() or using an on-commit response wrapper/header-writer mechanism so the header is reliably written before commit.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3bdca67. Added a commit guard for the late write path: !response.isCommitted() before attempting to set X-Trace-Id after filterChain.doFilter(...).

}

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;
}
}


Original file line number Diff line number Diff line change
@@ -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;
}
}

Loading