Remove payload logging from shared trace configuration
All checks were successful
Deploy Gateway / deploy (push) Successful in 1m38s

This commit is contained in:
2026-08-18 15:44:45 +09:00
parent 4e45a8f321
commit 0a21031a42
11 changed files with 70 additions and 170 deletions

View File

@@ -187,14 +187,9 @@ public record McpProperties(
}
/**
* MCP 경계 로그와 선택적인 payload logging 정책 설정입니다.
* payload logging은 Agent·Tool 요청/응답 본문을 남기므로 로컬 검증처럼 명시적으로 켠 환경에서만 사용합니다.
* MCP 경계 로그 활성화와 수신 요청 최대 크기 정책 설정입니다.
*/
public record Trace(
boolean enabled,
@Min(1) int maxBodyBytes,
boolean payloadLoggingEnabled,
@Min(1) int maxPayloadBytes) {
public record Trace(boolean enabled, @Min(1) int maxBodyBytes) {
}
/**

View File

@@ -1,11 +1,5 @@
package io.shinhanlife.dap.biz.mcp.observability;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
@@ -15,30 +9,26 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 로그로 남기는 관측 컴포넌트입니다.
* 일반 trace는 key=value 형식으로 유지하고, payload 로그는 명시적으로 켜진 검증 환경에서만 JSON 한 줄로 남깁니다.
* 요청 본문과 Tool 응답에는 업무 데이터가 포함될 수 있으므로 기본값은 비활성화이며, 주요 의존성은 trace 설정을 가진 {@link McpProperties}와 JSON 직렬화를 담당하는 {@link ObjectMapper}입니다.
* MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다.
* 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다.
* payload 본문은 이 공통 logger가 보관하지 않고, 임시 검증이 필요한 경계 클래스에서 삭제하기 쉬운 별도 {@code TEMP_*} 로그로만 남깁니다.
*/
@Component
public class TraceLogger {
private static final Logger log = LoggerFactory.getLogger(TraceLogger.class);
private final McpProperties properties;
private final ObjectMapper objectMapper;
/**
* trace 설정과 payload JSON 직렬화기를 주입받습니다.
* payload logging 활성 여부와 최대 기록 크기는 {@code mcp.trace.*} 설정으로 판단합니다.
* trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다.
*/
public TraceLogger(McpProperties properties, ObjectMapper objectMapper) {
public TraceLogger(McpProperties properties) {
this.properties = properties;
this.objectMapper = objectMapper;
}
/**
* 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다.
* payload는 포함하지 않으며, trace 로그 설정이 켜진 경우에만 기록니다.
* trace 로그 설정이 켜진 경우에만 기록하며, 요청·응답 본문은 포함하지 않습니다.
*/
public void event(String event, Object... keyValues) {
if (properties.trace().enabled()) {
@@ -52,32 +42,9 @@ public class TraceLogger {
}
}
/**
* Agent와 MCP, MCP와 Tool Server 사이의 요청·응답 payload를 JSON 한 줄 로그로 남깁니다.
* 업무 payload 노출 위험 때문에 {@code mcp.trace.payload-logging-enabled=true}인 경우에만 동작하고, 설정된 byte 상한을 넘으면 본문 대신 축약 메시지를 남깁니다.
*/
public void payload(String event, Object payload, Object... keyValues) {
if (!properties.trace().enabled() || !properties.trace().payloadLoggingEnabled()) {
return;
}
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
ObjectNode envelope = objectMapper.createObjectNode();
envelope.put("event", safe(event));
envelope.put("guid", context == null ? "" : context.guid());
envelope.put("requestId", context == null ? "" : context.requestId());
envelope.put("routeKey", context == null ? "" : context.routeKey());
addFields(envelope, keyValues);
envelope.set("payload", boundedPayload(payload));
try {
log.info(objectMapper.writeValueAsString(envelope));
} catch (JsonProcessingException exception) {
log.warn("event={} payloadLoggingFailed=true reason={}", safe(event), exception.getClass().getSimpleName());
}
}
/**
* 예외가 발생한 처리 단계를 오류 로그로 남깁니다.
* 오류 로그에 payload 포함하지 않고, 예외 종류와 메시지만 correlation 값과 함께 남깁니다.
* 오류 로그에 payload 포함하지 않고 예외 종류와 메시지만 correlation 값과 함께 남깁니다.
*/
public void error(String event, Throwable error, Object... keyValues) {
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
@@ -104,59 +71,6 @@ public class TraceLogger {
return joiner.toString();
}
/**
* payload 로그 envelope에 추가 필드를 안전한 JSON 값으로 넣습니다.
* 필드명은 로그 검색에 쓰이는 메타데이터만 받는 전제라 개행과 공백을 정리합니다.
*/
private void addFields(ObjectNode envelope, Object... keyValues) {
for (int index = 0; index + 1 < keyValues.length; index += 2) {
String key = safe(keyValues[index]);
Object value = keyValues[index + 1];
if (!key.isBlank()) {
envelope.set(key, objectMapper.valueToTree(value));
}
}
}
/**
* payload를 JSON 노드로 바꾸고 설정된 최대 byte 수를 넘으면 축약 문자열로 대체합니다.
* 문자열 payload가 JSON이면 JSON으로 파싱하고, 그렇지 않으면 문자열 그대로 기록합니다.
*/
private JsonNode boundedPayload(Object payload) {
JsonNode node = payloadNode(payload);
try {
String serialized = objectMapper.writeValueAsString(node);
if (serialized.getBytes(java.nio.charset.StandardCharsets.UTF_8).length
> properties.trace().maxPayloadBytes()) {
return TextNode.valueOf("<payload omitted: exceeds maxPayloadBytes>");
}
return node;
} catch (JsonProcessingException exception) {
return TextNode.valueOf("<payload omitted: serialization failed>");
}
}
/**
* 다양한 payload 입력을 JSON 노드로 정규화합니다.
* 이미 JsonNode면 그대로 쓰고, 문자열은 JSON 파싱을 먼저 시도한 뒤 실패하면 text node로 보관합니다.
*/
private JsonNode payloadNode(Object payload) {
if (payload == null) {
return NullNode.getInstance();
}
if (payload instanceof JsonNode jsonNode) {
return jsonNode;
}
if (payload instanceof String text) {
try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
return TextNode.valueOf(text);
}
}
return objectMapper.valueToTree(payload);
}
/**
* 줄바꿈과 공백을 치환해 로그 이벤트가 여러 줄로 갈라지지 않게 합니다.
*/

View File

@@ -5,13 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
import java.net.SocketTimeoutException;
import java.net.http.HttpClient;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
@@ -28,10 +29,11 @@ import org.springframework.web.client.RestClientException;
@Component
public class HttpToolClient implements ToolClient {
private static final Logger log = LoggerFactory.getLogger(HttpToolClient.class);
private final ObjectMapper objectMapper;
private final McpProperties properties;
private final HttpClient toolHttpClient;
private final TraceLogger traceLogger;
/**
* JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다.
@@ -39,12 +41,10 @@ public class HttpToolClient implements ToolClient {
public HttpToolClient(
ObjectMapper objectMapper,
McpProperties properties,
@Qualifier("toolHttpClient") HttpClient toolHttpClient,
TraceLogger traceLogger) {
@Qualifier("toolHttpClient") HttpClient toolHttpClient) {
this.objectMapper = objectMapper;
this.properties = properties;
this.toolHttpClient = toolHttpClient;
this.traceLogger = traceLogger;
}
/**
@@ -53,17 +53,12 @@ public class HttpToolClient implements ToolClient {
@Override
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
try {
traceLogger.payload(
"tool_http_request_body",
request.arguments(),
"direction",
"mcp_to_tool_server",
"toolName",
log.info(
"TEMP_TOOL_HTTP_REQUEST_BODY direction=mcp_to_tool_server toolName={} version={} endpoint={} body={}",
request.toolName(),
"version",
request.version(),
"endpoint",
request.endpoint());
request.endpoint(),
request.arguments());
RestClient.RequestBodySpec spec = requestSpec(request, context);
var entity =
spec.retrieve()
@@ -73,18 +68,12 @@ public class HttpToolClient implements ToolClient {
throw statusException(response.getStatusCode().value(), request.toolName());
})
.toEntity(String.class);
JsonNode responseBody = parseResponse(entity.getBody(), entity.getHeaders().getContentType());
traceLogger.payload(
"tool_http_response_body",
responseBody,
"direction",
"tool_server_to_mcp",
"toolName",
JsonNode responseBody = parseResponse(entity.getBody(), entity.getHeaders().getContentType()); log.info(
"TEMP_TOOL_HTTP_RESPONSE_BODY direction=tool_server_to_mcp toolName={} version={} statusCode={} body={}",
request.toolName(),
"version",
request.version(),
"statusCode",
entity.getStatusCode().value());
entity.getStatusCode().value(),
responseBody);
return new ToolResponse(entity.getStatusCode().value(), responseBody);
} catch (ToolClientException exception) {
throw exception;

View File

@@ -1,6 +1,7 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
@@ -9,8 +10,9 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@@ -24,20 +26,22 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class McpController {
private static final Logger log = LoggerFactory.getLogger(McpController.class);
public static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id";
private final JsonRpcRequestParser requestParser;
private final McpMethodHandlerRegistry handlerRegistry;
private final TraceLogger traceLogger;
private final ObjectMapper objectMapper;
/**
* JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다.
*/
public McpController(
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, TraceLogger traceLogger) {
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, ObjectMapper objectMapper) {
this.requestParser = requestParser;
this.handlerRegistry = handlerRegistry;
this.traceLogger = traceLogger;
this.objectMapper = objectMapper;
}
/**
@@ -55,15 +59,11 @@ public class McpController {
try {
JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context);
if (request.notification()) {
traceLogger.payload(
"mcp_http_response_body",
null,
"direction",
"mcp_to_agent",
"mcpMethod",
log.info(
"TEMP_MCP_HTTP_RESPONSE_BODY direction=mcp_to_agent mcpMethod={} httpStatus={} body={}",
request.method(),
"httpStatus",
202);
202,
"");
return ResponseEntity.accepted().build();
}
ResponseEntity.BodyBuilder responseBuilder =
@@ -71,15 +71,11 @@ public class McpController {
if ("initialize".equals(request.method())) {
responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString());
}
traceLogger.payload(
"mcp_http_response_body",
response,
"direction",
"mcp_to_agent",
"mcpMethod",
log.info(
"TEMP_MCP_HTTP_RESPONSE_BODY direction=mcp_to_agent mcpMethod={} httpStatus={} body={}",
request.method(),
"httpStatus",
200);
200,
toJson(response));
return responseBuilder.body(response);
} catch (JsonRpcException exception) {
if (exception.requestId() != null) {
@@ -92,4 +88,16 @@ public class McpController {
JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error", request.id(), exception);
}
}
/**
* 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다.
* 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다.
*/
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception exception) {
return String.valueOf(value);
}
}
}

View File

@@ -16,6 +16,8 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
@@ -30,6 +32,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class McpExchangeFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(McpExchangeFilter.class);
private final McpRequestContextFactory headerExtractor;
private final TraceLogger traceLogger;
private final ObjectMapper objectMapper;
@@ -103,18 +107,12 @@ public class McpExchangeFilter extends OncePerRequestFilter {
"path",
request.getRequestURI(),
"mcpMethod",
mcpMethod);
traceLogger.payload(
"mcp_http_request_body",
cachedRequest.bodyText(),
"direction",
"agent_to_mcp",
"httpMethod",
mcpMethod); log.info(
"TEMP_MCP_HTTP_REQUEST_BODY direction=agent_to_mcp httpMethod={} path={} mcpMethod={} body={}",
request.getMethod(),
"path",
request.getRequestURI(),
"mcpMethod",
mcpMethod);
mcpMethod,
cachedRequest.bodyText());
try {
protocolVersionValidator.validatePostInitializeRequest(request, mcpMethod);

View File

@@ -93,11 +93,7 @@ mcp:
# nothing a Tool Service returns can change where MCP sends the call.
bundles: []
trace:
enabled: true
# Request/response body logging is for temporary verification only. Keep disabled by default.
payload-logging-enabled: ${MCP_TRACE_PAYLOAD_LOGGING_ENABLED:true}
max-payload-bytes: ${MCP_TRACE_MAX_PAYLOAD_BYTES:65536}
# Rejects oversized MCP request bodies before controller processing.
enabled: true # Rejects oversized MCP request bodies before controller processing.
max-body-bytes: 1048576
logging:

View File

@@ -29,7 +29,7 @@ public final class TestFixtures {
"file:./config/local-core-tools-manifest-sample-v1.json", 30, 5),
new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization, "tool-server-key"),
new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"),
new McpProperties.Trace(true, 1_048_576, false, 65_536),
new McpProperties.Trace(true, 1_048_576),
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
new McpProperties.Portal(false, "", "", 300),

View File

@@ -26,7 +26,7 @@ class TraceLoggerTest {
logger.addAppender(appender);
McpRequestContextHolder.set(context());
new TraceLogger(properties(false, false), io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER)
new TraceLogger(properties(false, false))
.event("mcp_http_response_completed", "httpStatus", 200);
assertThat(appender.list)

View File

@@ -41,7 +41,7 @@ class HttpToolClientTest {
.setHeader("Content-Type", "application/json")
.setBody("{\"customerName\":\"홍길동\"}"));
HttpToolClient client =
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
ToolRequest request =
new ToolRequest(
"customer.search",
@@ -75,7 +75,7 @@ class HttpToolClientTest {
server.enqueue(new MockResponse().setHeader("Content-Type", "application/json")
.setBody("{\"quote\":\"시작이 반이다.\"}"));
HttpToolClient client =
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
ToolRequest request = new ToolRequest(
"smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(),
OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000);
@@ -97,7 +97,7 @@ class HttpToolClientTest {
void preservesPlainTextToolResponseAsTextNode() throws Exception {
server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("123"));
HttpToolClient client =
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
ToolRequest request =
new ToolRequest(
"processing",
@@ -116,7 +116,7 @@ class HttpToolClientTest {
void preservesHttpStatusOnToolError() throws Exception {
server.enqueue(new MockResponse().setResponseCode(410).setBody("gone"));
HttpToolClient client =
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient(), new io.shinhanlife.dap.biz.mcp.observability.TraceLogger(properties(false, false), OBJECT_MAPPER));
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
ToolRequest request =
new ToolRequest(
"customer.search",

View File

@@ -41,7 +41,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isNull();
@@ -64,7 +64,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
assertThat(sessionId).isNotBlank();
@@ -89,7 +89,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
new McpController(parser, registry, org.mockito.Mockito.mock(io.shinhanlife.dap.biz.mcp.observability.TraceLogger.class)).handleMcpRequest(JsonNodeFactory.instance.objectNode());
new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
PostMapping mapping =
McpController.class

View File

@@ -219,7 +219,7 @@ class McpExchangeFilterTest {
base.registry(),
base.toolClient(),
base.redis(),
new McpProperties.Trace(true, 8, false, 65_536),
new McpProperties.Trace(true, 8),
base.protocol(),
base.discovery(),
base.portal(),
@@ -357,7 +357,7 @@ class McpExchangeFilterTest {
private McpExchangeFilter filter(McpProperties properties) {
return new McpExchangeFilter(
new McpRequestContextFactory(properties),
new TraceLogger(properties, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER),
new TraceLogger(properties),
OBJECT_MAPPER,
properties,
new McpProtocolVersionValidator(properties));