diff --git a/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java b/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java index 07dde77..166ef98 100644 --- a/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java +++ b/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java @@ -48,19 +48,13 @@ private ObsMetrics(ObsSdkConfig cfg) { this.defaultCommunity = cfg.community(); this.namespace = cfg.namespace(); - List common = new ArrayList<>(); - if (service != null) { - common.add(Tag.of("service", service)); - } - if (envName != null) { - common.add(Tag.of("env", envName)); - } - if (instance != null) { - common.add(Tag.of("instance", instance)); - } - if (!common.isEmpty()) { - registry.config().commonTags(Tags.of(common)); - } + // service/env/instance 恒为 const label,取值恒非空(ObsSdkConfig 已做三级兜底, + // 见 internal/Env),故无条件注册。条件注册会让未配置的字段整个缺席, + // 与 Go/Python/Node 输出的 label 集不一致 —— 大盘按 label 过滤时会漏掉这些服务。 + registry.config().commonTags(Tags.of( + Tag.of("service", service), + Tag.of("env", envName), + Tag.of("instance", instance))); } public static ObsMetrics of(ObsSdkConfig cfg) { @@ -125,10 +119,8 @@ private static List tags(String defaultCommunity, String[] labelNames, Stri throw new IllegalArgumentException("label 数量不匹配: names=" + labelNames.length + " values=" + values.length); } List tags = new ArrayList<>(labelNames.length + 1); - String community = communityValue(defaultCommunity); - if (community != null) { - tags.add(Tag.of("community", community)); - } + // community 恒非空(覆盖值或部署默认,见 ObsSdkConfig 兜底),恒排首位。 + tags.add(Tag.of("community", communityValue(defaultCommunity))); for (int i = 0; i < labelNames.length; i++) { tags.add(Tag.of(labelNames[i], values[i] == null ? "" : values[i])); } @@ -183,10 +175,8 @@ private AtomicReference child(String... labelValues) { String community = communityValue(ObsMetrics.this.defaultCommunity); List tags = new ArrayList<>(labelNames.length + 1); List keyParts = new ArrayList<>(labelNames.length + 1); - keyParts.add(community == null ? "" : community); - if (community != null) { - tags.add(Tag.of("community", community)); - } + keyParts.add(community); + tags.add(Tag.of("community", community)); for (int i = 0; i < labelNames.length; i++) { String v = labelValues[i] == null ? "" : labelValues[i]; tags.add(Tag.of(labelNames[i], v)); diff --git a/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java b/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java index d28e332..07c683b 100644 --- a/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java +++ b/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java @@ -1,18 +1,26 @@ package io.opensourceways.obssdk; +import io.opensourceways.obssdk.internal.Env; + /** * SDK 静态配置(部署级默认字段)。 * *

与 spec/common-fields.md 对齐:统一从环境变量读取默认值 * {@code OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY}, * 语义与 Go/Python/Node SDK 的 Config 一致。 + * + *

三级解析(同 Go 侧 {@code internal/env}):显式参数 > {@code OBS_*} + * 环境变量 > 内置默认。内置默认为契约规定值:{@code service} / {@code env} / + * {@code community} = {@code "unknown"},{@code instance} = hostname(k8s 下即 pod 名)。 + * 因此四个字段取值恒非 null、恒非空——不设兜底时它们会整条从日志 JSON 里消失 + * (provider 对空值省略该键),也进不了指标 label 集。 */ public final class ObsSdkConfig { - public static final String ENV_SERVICE = "OBS_SERVICE"; - public static final String ENV_ENV = "OBS_ENV"; - public static final String ENV_INSTANCE = "OBS_INSTANCE"; - public static final String ENV_COMMUNITY = "OBS_COMMUNITY"; + public static final String ENV_SERVICE = Env.ENV_SERVICE; + public static final String ENV_ENV = Env.ENV_ENV; + public static final String ENV_INSTANCE = Env.ENV_INSTANCE; + public static final String ENV_COMMUNITY = Env.ENV_COMMUNITY; private final String service; private final String envName; @@ -21,40 +29,43 @@ public final class ObsSdkConfig { private final String namespace; private ObsSdkConfig(Builder b) { - this.service = b.service; - this.envName = b.envName; - this.instance = b.instance; - this.community = b.community; + // 构造时即完成三级解析:调用方显式设置 > OBS_* 环境变量 > 内置默认。 + this.service = Env.service(b.service); + this.envName = Env.env(b.envName); + this.instance = Env.instance(b.instance); + this.community = Env.community(b.community); this.namespace = b.namespace; } - /** 按规范优先级读取环境变量;调用方显式设置的值优先。 */ + /** 构建配置;四个部署级字段的取值在 {@link Builder#build()} 时完成解析。 */ public static Builder builder() { return new Builder(); } - /** 只从环境变量构造(测试外入口)。 */ + /** + * 全部取环境变量/内置默认(测试外入口,等价于 {@code builder().build()})。 + * 未设置 {@code OBS_*} 的字段回退到契约默认,不会为空。 + */ public static ObsSdkConfig fromEnvironment() { - return builder() - .service(System.getenv(ENV_SERVICE)) - .env(System.getenv(ENV_ENV)) - .instance(System.getenv(ENV_INSTANCE)) - .community(System.getenv(ENV_COMMUNITY)) - .build(); + return builder().build(); } + /** 服务名(恒非空,未配置时为 "unknown")。 */ public String service() { return service; } + /** 部署环境(恒非空,未配置时为 "unknown")。 */ public String envName() { return envName; } + /** 实例标识(恒非空,未配置时取 hostname)。 */ public String instance() { return instance; } + /** 部署级默认社区(恒非空,未配置时为 "unknown")。 */ public String community() { return community; } diff --git a/java/src/main/java/io/opensourceways/obssdk/internal/Env.java b/java/src/main/java/io/opensourceways/obssdk/internal/Env.java new file mode 100644 index 0000000..5e1934c --- /dev/null +++ b/java/src/main/java/io/opensourceways/obssdk/internal/Env.java @@ -0,0 +1,96 @@ +package io.opensourceways.obssdk.internal; + +import java.net.InetAddress; + +/** + * 通用字段环境变量解析辅助(对齐 Go 侧 {@code go/internal/env})。 + * + *

四语言 SDK 统一读同一套 {@code OBS_*} 环境变量(见 spec/common-fields.md), + * 保证部署配置模板(helm values)不因语言而异。 + * + *

解析优先级:**显式参数 > 环境变量 > 内置默认**。内置默认取契约规定值 + * (spec/common-fields.md「静态字段来源与默认值」):{@code service} / {@code env} / + * {@code community} 为 {@code "unknown"},{@code instance} 为 hostname。 + * + *

为什么要兜底:日志侧的空值一律「省略该键」(见 log/ ObsJsonProvider), + * 缺了内置默认这几个字段就会**整条从 JSON 里消失**,而不是显示 unknown —— 采集侧 + * 按字段建索引/过滤时会静默漏数;指标侧同理,未设置的字段不会进 label 集, + * 与 Go / Python / Node 输出的 label 集对不上。 + * + *

本包为 SDK 内部实现,不属于对外 API。 + */ +public final class Env { + + /** 字段名/环境变量名:服务名。 */ + public static final String ENV_SERVICE = "OBS_SERVICE"; + /** 字段名/环境变量名:部署环境。 */ + public static final String ENV_ENV = "OBS_ENV"; + /** 字段名/环境变量名:实例标识。 */ + public static final String ENV_INSTANCE = "OBS_INSTANCE"; + /** 字段名/环境变量名:部署级默认社区。 */ + public static final String ENV_COMMUNITY = "OBS_COMMUNITY"; + + /** 契约规定的内置默认值({@code instance} 例外,见 {@link #instance})。 */ + public static final String DEFAULT_VALUE = "unknown"; + + private Env() { + } + + /** 服务名:显式非空优先,否则读 {@code OBS_SERVICE},再否则 "unknown"。 */ + public static String service(String explicit) { + return resolve(explicit, ENV_SERVICE, DEFAULT_VALUE); + } + + /** 部署环境:显式非空优先,否则读 {@code OBS_ENV},再否则 "unknown"。 */ + public static String env(String explicit) { + return resolve(explicit, ENV_ENV, DEFAULT_VALUE); + } + + /** 实例标识:显式非空优先,否则读 {@code OBS_INSTANCE},再否则 hostname。 */ + public static String instance(String explicit) { + return resolve(explicit, ENV_INSTANCE, hostname()); + } + + /** 社区:显式非空优先,否则读 {@code OBS_COMMUNITY},再否则 "unknown"。 */ + public static String community(String explicit) { + return resolve(explicit, ENV_COMMUNITY, DEFAULT_VALUE); + } + + /** + * 三级解析:显式参数 > 环境变量 > 兜底值。 + * 空串与 null 一样视为「未设置」(与 Go 侧 {@code explicit != ""} 判定一致)。 + */ + static String resolve(String explicit, String envKey, String fallback) { + if (explicit != null && !explicit.isEmpty()) { + return explicit; + } + String fromEnv = System.getenv(envKey); + if (fromEnv != null && !fromEnv.isEmpty()) { + return fromEnv; + } + return fallback; + } + + /** + * 取本机 hostname,语义对齐 Go 侧 {@code os.Hostname()}(k8s 下即 pod 名)。 + * + *

先读 {@code HOSTNAME} 环境变量:Linux / k8s 由容器运行时写入,无需 DNS 解析、 + * 代价最低;再退回 {@link InetAddress#getLocalHost()}(容器内 /etc/hosts 缺失时 + * 可能抛 {@code UnknownHostException},故最后兜到 "unknown")。 + */ + private static String hostname() { + String fromEnv = System.getenv("HOSTNAME"); + if (fromEnv != null && !fromEnv.isEmpty()) { + return fromEnv; + } + try { + String fromLookup = InetAddress.getLocalHost().getHostName(); + if (fromLookup != null && !fromLookup.isEmpty()) { + return fromLookup; + } + } catch (Exception ignored) { + // 解析失败(无 DNS / 无 hosts 记录)→ 退到内置默认 + } + return DEFAULT_VALUE; + } +} diff --git a/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java b/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java index 81e276c..d28c0e0 100644 --- a/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java +++ b/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java @@ -2,6 +2,7 @@ import io.opensourceways.obssdk.ObsSdkConfig; import io.opensourceways.obssdk.context.RequestContext; +import io.opensourceways.obssdk.internal.Env; import org.slf4j.MDC; import java.util.Optional; @@ -62,8 +63,7 @@ public static void enrich(RequestContext request) { ? Optional.of(request.spanId()) : Optional.empty(); - String base = cfg != null ? cfg.community() : null; - MDC.put(MDC_COMMUNITY, community.orElse(base != null ? base : "")); + MDC.put(MDC_COMMUNITY, community.orElse(deploymentCommunity())); if (requestId.isPresent()) { MDC.put(MDC_REQUEST_ID, requestId.get()); } @@ -75,11 +75,25 @@ public static void enrich(RequestContext request) { } } - /** 请求结束时清理请求级字段,避免线程复用串染(MDC 由框架在线程回收时兜底)。 */ + /** + * 请求结束时清理请求级字段,避免线程复用串染(MDC 由框架在线程回收时兜底)。 + * + *

{@code community} 必须**复位为部署级默认**而不是删掉或留着:MDC 是线程本地的, + * 而 web 容器的请求线程是复用的 —— 删掉会连部署默认一起丢(字段整个消失), + * 留着则会把上一个请求的覆盖值(如 mindspore)串给下一个请求之外打出的日志 + * (定时任务 / 异步回调),且不报错,属于静默污染:按 community 的聚合与告警会失真。 + */ public static void clearRequestScope() { MDC.remove(MDC_REQUEST_ID); MDC.remove(MDC_TRACE_ID); MDC.remove(MDC_SPAN_ID); + MDC.put(MDC_COMMUNITY, deploymentCommunity()); + } + + /** 部署级默认 community;{@code init} 未调用时退到契约默认(见 internal/Env)。 */ + private static String deploymentCommunity() { + ObsSdkConfig current = cfg; + return current != null && current.community() != null ? current.community() : Env.DEFAULT_VALUE; } private static String nvl(String v) { diff --git a/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java b/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java index 7de8c9f..ccee651 100644 --- a/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java +++ b/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java @@ -2,6 +2,7 @@ import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.joran.JoranConfigurator; +import io.opensourceways.obssdk.context.RequestContext; import io.opensourceways.obssdk.log.ObsLogging; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -156,6 +157,48 @@ void tearDown() { assertFalse(json.contains("span_id"), json); } + /** + * 回归:未显式配置且无 {@code OBS_*} 环境变量时,四个部署级字段仍须出现在输出里。 + * + *

此前 {@code fromEnvironment()} 无兜底 → 取值 null → provider 省略空值 → 这四个键 + * 整条从 JSON 里消失(而不是显示 unknown),采集侧按字段建索引会静默漏数。 + */ + @Test + void 未显式配置时四个部署级字段仍出现且非空() { + ObsLogging.init(ObsSdkConfig.builder().build()); + + LoggerFactory.getLogger("com.x.ReviewSvc").info("job done"); + String json = lastLine(); + + for (String key : new String[]{"service", "env", "instance", "community"}) { + String value = fieldValue(json, key); // 字段缺席直接断言失败 + assertFalse(value.isEmpty(), key + " 是空值: " + json); + } + } + + /** + * 回归:请求结束({@code clearRequestScope})后,请求之外的日志不应带上 + * 上一个请求的 community 覆盖值。 + * + *

MDC 是线程本地的,web 容器线程复用;残留的覆盖值会静默污染定时任务 / + * 异步回调打出的日志,使按 community 的聚合与告警失真。这条断言落在真实 JSON + * 输出上,而不是 MDC 中间变量。 + */ + @Test + void 请求结束后再打日志_community为部署默认而非上次请求的覆盖值() { + try (RequestContext.Scope scope = RequestContext.push("mindspore", "req-1", "trace-1")) { + ObsLogging.enrich(RequestContext.current().orElse(null)); + } finally { + ObsLogging.clearRequestScope(); + } + + LoggerFactory.getLogger("com.x.ReviewSvc").info("cron tick"); + String json = lastLine(); + + assertTrue(json.contains("\"community\":\"openEuler\""), json); + assertFalse(json.contains("mindspore"), "上一个请求的 community 串染了:" + json); + } + @Test void 异常写入error字段且保留完整堆栈() { LoggerFactory.getLogger("com.x.ReviewSvc") diff --git a/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java b/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java index d98f5ae..68854f7 100644 --- a/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java +++ b/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java @@ -63,4 +63,23 @@ void tearDown() { assertNull(MDC.get(ObsLogging.MDC_SPAN_ID)); } } + + /** + * 回归:{@code clearRequestScope} 曾漏清 community。 + * + *

MDC 是线程本地的,web 容器线程复用 —— 留着上一个请求的覆盖值会串给同一线程 + * 后续打出的日志(定时任务 / 异步回调),且不报错。注意既要复位成部署默认, + * 也不能删掉(删了字段会整个从 JSON 里消失)。 + */ + @Test + void clearRequestScope把community复位为部署默认() { + try (RequestContext.Scope s = RequestContext.push("mindspore", "req-1", "trace-1")) { + ObsLogging.enrich(RequestContext.current().orElse(null)); + assertEquals("mindspore", MDC.get(ObsLogging.MDC_COMMUNITY)); + + ObsLogging.clearRequestScope(); + assertEquals("openeuler", MDC.get(ObsLogging.MDC_COMMUNITY), + "应复位为部署默认,而不是残留上一个请求的覆盖值"); + } + } } diff --git a/java/src/test/java/io/opensourceways/obssdk/ObsMetricsTest.java b/java/src/test/java/io/opensourceways/obssdk/ObsMetricsTest.java index a8d355f..e25adac 100644 --- a/java/src/test/java/io/opensourceways/obssdk/ObsMetricsTest.java +++ b/java/src/test/java/io/opensourceways/obssdk/ObsMetricsTest.java @@ -7,6 +7,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ObsMetricsTest { @@ -102,6 +103,27 @@ private static double value(String sampleLine) { assertTrue(m.text().contains("obs_events_total"), m.text()); } + /** + * 回归:四个部署级字段在未显式配置时也必须出现在 label 集里。 + * + *

此前 {@code fromEnvironment()} 无兜底,取值可能为 null;{@code community} 为 null 时 + * {@code Tag.of} 直接 NPE,{@code service/env/instance} 为 null 时 commonTags 被整段跳过 —— + * 同一份大盘查询,Java 服务比 Go 服务少几个 label,按 label 过滤时静默漏掉这些服务。 + */ + @Test + void 未显式配置时四个部署级label仍非空() { + ObsMetrics m = ObsMetrics.of(ObsSdkConfig.builder().build()); + m.counter("events", "事件数").inc(1); + + List ev = samples(m.text(), "events_total"); + assertEquals(1, ev.size()); + String line = ev.get(0); + for (String label : new String[]{"service", "env", "instance", "community"}) { + assertTrue(line.contains(label + "=\""), "缺少 label " + label + ": " + line); + assertFalse(line.contains(label + "=\"\""), label + " 值为空: " + line); + } + } + @Test void histogram自定义桶() { ObsMetrics m = ObsMetrics.of(cfg("service", "s", "community", "openeuler")); diff --git a/java/src/test/java/io/opensourceways/obssdk/internal/EnvTest.java b/java/src/test/java/io/opensourceways/obssdk/internal/EnvTest.java new file mode 100644 index 0000000..257ad7f --- /dev/null +++ b/java/src/test/java/io/opensourceways/obssdk/internal/EnvTest.java @@ -0,0 +1,70 @@ +package io.opensourceways.obssdk.internal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * 静态字段三级解析:显式参数 > {@code OBS_*} 环境变量 > 内置默认。 + * + *

这一层此前缺失兜底,导致未注入环境变量时 {@code service}/{@code env}/ + * {@code instance}/{@code community} 四个**必填**字段整个从日志 JSON 里消失 + * (见 spec/common-fields.md)。 + */ +class EnvTest { + + /** 必然未设置的键,用于验证「环境变量缺失 → 兜底」这一级。 */ + private static final String UNSET_KEY = "OBS_SDK_TEST_KEY_THAT_IS_NEVER_SET"; + + @Test + void 显式参数优先于环境变量与兜底值() { + assertEquals("review", Env.resolve("review", UNSET_KEY, "unknown")); + // 该环境变量确实有值时,显式参数仍优先 + assertEquals("review", Env.resolve("review", "PATH", "unknown")); + } + + @Test + void 环境变量缺失时回退兜底值() { + assertNull(System.getenv(UNSET_KEY), "该键不应被设置,测试前提不成立"); + assertEquals("unknown", Env.resolve(null, UNSET_KEY, "unknown")); + } + + @Test + void 空串与null一样视为未设置() { + // 空串若被当作有效值,日志里就会出现 "service":"",等价于字段缺失 + assertEquals("unknown", Env.resolve("", UNSET_KEY, "unknown")); + assertEquals("unknown", Env.resolve(null, UNSET_KEY, "unknown")); + } + + @Test + void 环境变量存在时优先于兜底值() { + String path = System.getenv("PATH"); + assumeTrue(path != null && !path.isEmpty(), "PATH 未设置,跳过"); + assertEquals(path, Env.resolve(null, "PATH", "unknown")); + } + + @Test + void 四个字段未配置时恒非空() { + // 契约默认:service/env/community = unknown,instance = hostname。 + // 只要有一个为 null/空,该字段就会从日志与指标里整条消失。 + String[] resolved = { + Env.service(null), Env.env(null), Env.instance(null), Env.community(null), + }; + for (String value : resolved) { + assertNotNull(value); + assertFalse(value.isEmpty()); + } + } + + @Test + void 常量名与契约一致() { + assertEquals("OBS_SERVICE", Env.ENV_SERVICE); + assertEquals("OBS_ENV", Env.ENV_ENV); + assertEquals("OBS_INSTANCE", Env.ENV_INSTANCE); + assertEquals("OBS_COMMUNITY", Env.ENV_COMMUNITY); + } +} diff --git a/java/src/test/java/io/opensourceways/obssdk/middleware/ObsFilterTest.java b/java/src/test/java/io/opensourceways/obssdk/middleware/ObsFilterTest.java new file mode 100644 index 0000000..0e156a7 --- /dev/null +++ b/java/src/test/java/io/opensourceways/obssdk/middleware/ObsFilterTest.java @@ -0,0 +1,216 @@ +package io.opensourceways.obssdk.middleware; + +import io.opensourceways.obssdk.ObsSdkConfig; +import io.opensourceways.obssdk.context.RequestContext; +import io.opensourceways.obssdk.log.ObsLogging; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link ObsFilter} 行为测试:request_id 注入、community 可信解析与回退、 + * 以及请求结束(含异常穿透)后的作用域清理。 + * + *

其中「请求结束后 community 复位为部署默认」是为一个真实缺陷补的回归测试: + * MDC 是线程本地的,而 web 容器的请求线程是复用的,若 {@code clearRequestScope()} + * 漏清 community,上一个请求的覆盖值会串给同一线程后续打出的日志(定时任务 / + * 异步回调),静默污染按 community 的聚合与告警。 + * + *

用 {@link Proxy} 造 servlet 接口替身,避免为一个测试引入 mock 框架。 + */ +class ObsFilterTest { + + private static final String DEFAULT_COMMUNITY = "openeuler"; + + @BeforeEach + void setUp() { + MDC.clear(); + RequestContext.clear(); + ObsLogging.init(ObsSdkConfig.builder() + .service("review") + .env("test") + .instance("pod-1") + .community(DEFAULT_COMMUNITY) + .build()); + } + + @AfterEach + void tearDown() { + MDC.clear(); + RequestContext.clear(); + } + + // ---- request_id ---- + + @Test + void 入站X_Request_Id被沿用() throws Exception { + AtomicReference seen = new AtomicReference<>(); + new ObsFilter().doFilter(request(Map.of(ObsFilter.HEADER_REQUEST_ID, "req-inbound")), response(), + (req, res) -> seen.set(MDC.get(ObsLogging.MDC_REQUEST_ID))); + + assertEquals("req-inbound", seen.get()); + } + + @Test + void 无入站请求头时生成request_id() throws Exception { + AtomicReference seen = new AtomicReference<>(); + new ObsFilter().doFilter(request(Map.of()), response(), + (req, res) -> seen.set(MDC.get(ObsLogging.MDC_REQUEST_ID))); + + assertNotNull(seen.get()); + assertFalse(seen.get().isEmpty()); + } + + @Test + void 无入站trace_id时不生成_预留位保持缺席() throws Exception { + AtomicReference seen = new AtomicReference<>(); + new ObsFilter().doFilter(request(Map.of()), response(), + (req, res) -> seen.set(MDC.get(ObsLogging.MDC_TRACE_ID))); + + assertNull(seen.get()); + } + + // ---- community 解析与回退 ---- + + @Test + void resolver返回值写入MDC与请求上下文() throws Exception { + AtomicReference inMdc = new AtomicReference<>(); + AtomicReference inContext = new AtomicReference<>(); + new ObsFilter(req -> "mindspore").doFilter(request(Map.of()), response(), (req, res) -> { + inMdc.set(MDC.get(ObsLogging.MDC_COMMUNITY)); + inContext.set(RequestContext.communityOverride().orElse(null)); + }); + + assertEquals("mindspore", inMdc.get()); + assertEquals("mindspore", inContext.get()); + } + + @Test + void resolver返回null时回退部署默认() throws Exception { + AtomicReference seen = new AtomicReference<>(); + new ObsFilter(req -> null).doFilter(request(Map.of()), response(), + (req, res) -> seen.set(MDC.get(ObsLogging.MDC_COMMUNITY))); + + assertEquals(DEFAULT_COMMUNITY, seen.get()); + } + + @Test + void 未配置resolver时退化为部署级单社区() throws Exception { + AtomicReference inMdc = new AtomicReference<>(); + AtomicReference inContext = new AtomicReference<>(); + new ObsFilter().doFilter(request(Map.of()), response(), (req, res) -> { + inMdc.set(MDC.get(ObsLogging.MDC_COMMUNITY)); + inContext.set(RequestContext.communityOverride().orElse(null)); + }); + + assertEquals(DEFAULT_COMMUNITY, inMdc.get()); + // 无覆盖:上下文里不应出现可读到的覆盖值(Optional.empty) + assertNull(inContext.get()); + } + + // ---- 作用域与 MDC 清理 ---- + + @Test + void 请求结束后community复位为部署默认而非上一请求的覆盖值() throws Exception { + // 第一个请求:resolver 给出覆盖值 + new ObsFilter(req -> "mindspore").doFilter(request(Map.of()), response(), (req, res) -> { + }); + assertEquals(DEFAULT_COMMUNITY, MDC.get(ObsLogging.MDC_COMMUNITY), + "请求结束未复位,覆盖值会留给同一线程的后续日志"); + + // 模拟线程复用:第二个请求无覆盖,读到的必须是部署默认而不是 mindspore + AtomicReference seen = new AtomicReference<>(); + new ObsFilter(req -> null).doFilter(request(Map.of()), response(), + (req, res) -> seen.set(MDC.get(ObsLogging.MDC_COMMUNITY))); + assertEquals(DEFAULT_COMMUNITY, seen.get()); + } + + @Test + void 请求结束后request_id被清掉() throws Exception { + new ObsFilter().doFilter(request(Map.of(ObsFilter.HEADER_REQUEST_ID, "req-1")), response(), + (req, res) -> { + }); + + assertNull(MDC.get(ObsLogging.MDC_REQUEST_ID)); + } + + @Test + void 链路抛异常时作用域与MDC仍被清理() throws Exception { + ObsFilter filter = new ObsFilter(req -> "mindspore"); + Map headers = new HashMap<>(); + headers.put(ObsFilter.HEADER_REQUEST_ID, "req-1"); + headers.put(ObsFilter.HEADER_TRACE_ID, "trace-1"); + + assertThrows(ServletException.class, () -> filter.doFilter(request(headers), response(), + (req, res) -> { + throw new ServletException("boom"); + })); + + assertNull(MDC.get(ObsLogging.MDC_REQUEST_ID)); + assertNull(MDC.get(ObsLogging.MDC_TRACE_ID)); + assertNull(MDC.get(ObsLogging.MDC_SPAN_ID)); + assertEquals(DEFAULT_COMMUNITY, MDC.get(ObsLogging.MDC_COMMUNITY)); + assertTrue(RequestContext.current().isEmpty(), "请求上下文未还原"); + } + + // ---- servlet 接口替身 ---- + + /** 只实现 {@code getHeader} 的 HttpServletRequest 替身;其余方法返回类型零值。 */ + private static HttpServletRequest request(Map headers) { + return (HttpServletRequest) Proxy.newProxyInstance( + ObsFilterTest.class.getClassLoader(), + new Class[]{HttpServletRequest.class}, + (proxy, method, args) -> { + if ("getHeader".equals(method.getName())) { + return headers.get((String) args[0]); + } + return zeroValue(method.getReturnType()); + }); + } + + private static HttpServletResponse response() { + return (HttpServletResponse) Proxy.newProxyInstance( + ObsFilterTest.class.getClassLoader(), + new Class[]{HttpServletResponse.class}, + (proxy, method, args) -> zeroValue(method.getReturnType())); + } + + /** Proxy 的 InvocationHandler 对未处理方法必须返回合法值:基本类型不能给 null。 */ + private static Object zeroValue(Class type) { + if (!type.isPrimitive() || type == void.class) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return (char) 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0f; + } + if (type == double.class) { + return 0d; + } + return 0; + } +}