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
32 changes: 11 additions & 21 deletions java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,13 @@ private ObsMetrics(ObsSdkConfig cfg) {
this.defaultCommunity = cfg.community();
this.namespace = cfg.namespace();

List<Tag> 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) {
Expand Down Expand Up @@ -125,10 +119,8 @@ private static List<Tag> tags(String defaultCommunity, String[] labelNames, Stri
throw new IllegalArgumentException("label 数量不匹配: names=" + labelNames.length + " values=" + values.length);
}
List<Tag> 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]));
}
Expand Down Expand Up @@ -183,10 +175,8 @@ private AtomicReference<Double> child(String... labelValues) {
String community = communityValue(ObsMetrics.this.defaultCommunity);
List<Tag> tags = new ArrayList<>(labelNames.length + 1);
List<String> 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));
Expand Down
43 changes: 27 additions & 16 deletions java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
package io.opensourceways.obssdk;

import io.opensourceways.obssdk.internal.Env;

/**
* SDK 静态配置(部署级默认字段)。
*
* <p>与 spec/common-fields.md 对齐:统一从环境变量读取默认值
* {@code OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY},
* 语义与 Go/Python/Node SDK 的 Config 一致。
*
* <p><b>三级解析</b>(同 Go 侧 {@code internal/env}):显式参数 &gt; {@code OBS_*}
* 环境变量 &gt; 内置默认。内置默认为契约规定值:{@code service} / {@code env} /
* {@code community} = {@code "unknown"},{@code instance} = hostname(k8s 下即 pod 名)。
* 因此<b>四个字段取值恒非 null、恒非空</b>——不设兜底时它们会整条从日志 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;
Expand All @@ -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;
}
Expand Down
96 changes: 96 additions & 0 deletions java/src/main/java/io/opensourceways/obssdk/internal/Env.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package io.opensourceways.obssdk.internal;

import java.net.InetAddress;

/**
* 通用字段环境变量解析辅助(对齐 Go 侧 {@code go/internal/env})。
*
* <p>四语言 SDK 统一读同一套 {@code OBS_*} 环境变量(见 spec/common-fields.md),
* 保证部署配置模板(helm values)不因语言而异。
*
* <p>解析优先级:**显式参数 &gt; 环境变量 &gt; 内置默认**。内置默认取契约规定值
* (spec/common-fields.md「静态字段来源与默认值」):{@code service} / {@code env} /
* {@code community} 为 {@code "unknown"},{@code instance} 为 hostname。
*
* <p>为什么要兜底:日志侧的空值一律「省略该键」(见 log/ ObsJsonProvider),
* 缺了内置默认这几个字段就会**整条从 JSON 里消失**,而不是显示 unknown —— 采集侧
* 按字段建索引/过滤时会静默漏数;指标侧同理,未设置的字段不会进 label 集,
* 与 Go / Python / Node 输出的 label 集对不上。
*
* <p>本包为 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);
}

/**
* 三级解析:显式参数 &gt; 环境变量 &gt; 兜底值。
* 空串与 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 名)。
*
* <p>先读 {@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;
}
}
20 changes: 17 additions & 3 deletions java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
Expand All @@ -75,11 +75,25 @@ public static void enrich(RequestContext request) {
}
}

/** 请求结束时清理请求级字段,避免线程复用串染(MDC 由框架在线程回收时兜底)。 */
/**
* 请求结束时清理请求级字段,避免线程复用串染(MDC 由框架在线程回收时兜底)。
*
* <p>{@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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,6 +157,48 @@ void tearDown() {
assertFalse(json.contains("span_id"), json);
}

/**
* 回归:未显式配置且无 {@code OBS_*} 环境变量时,四个部署级字段仍须出现在输出里。
*
* <p>此前 {@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 覆盖值。
*
* <p>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")
Expand Down
19 changes: 19 additions & 0 deletions java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,23 @@ void tearDown() {
assertNull(MDC.get(ObsLogging.MDC_SPAN_ID));
}
}

/**
* 回归:{@code clearRequestScope} 曾漏清 community。
*
* <p>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),
"应复位为部署默认,而不是残留上一个请求的覆盖值");
}
}
}
22 changes: 22 additions & 0 deletions java/src/test/java/io/opensourceways/obssdk/ObsMetricsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -102,6 +103,27 @@ private static double value(String sampleLine) {
assertTrue(m.text().contains("obs_events_total"), m.text());
}

/**
* 回归:四个部署级字段在未显式配置时也必须出现在 label 集里。
*
* <p>此前 {@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<String> 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"));
Expand Down
Loading
Loading