chore(integration): stage observability validation on big-main

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 12:02:45 +08:00
commit 85fb05ff8f
60 changed files with 2693 additions and 118 deletions

View file

@ -53,6 +53,19 @@ API_PORT=8080
WEB_PORT=80
SESSION_COOKIE_SECURE=false
# Observability defaults require no Collector or tracing backend.
# Use json in container deployments when stdout is collected centrally.
SKILLHUB_TRACING_MODE=none
SKILLHUB_LOG_FORMAT=text
SKILLHUB_LOG_ASYNC_QUEUE_SIZE=1024
SKILLHUB_SERVICE_VERSION=unknown
SKILLHUB_SERVICE_ENVIRONMENT=production
SKILLHUB_TRACING_SAMPLING_PROBABILITY=0.1
# Set only with SKILLHUB_TRACING_MODE=otel-sdk.
MANAGEMENT_OTLP_TRACING_ENDPOINT=
SKILLHUB_OTLP_TIMEOUT=5s
SKILLHUB_OTLP_COMPRESSION=gzip
# Zero-config runtime validation uses local storage.
# Switch to `s3` and fill the fields below before a real production deployment.
SKILLHUB_STORAGE_PROVIDER=local

View file

@ -88,6 +88,15 @@ services:
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-text}
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
SKILLHUB_SERVICE_VERSION: ${SKILLHUB_SERVICE_VERSION:-unknown}
SKILLHUB_SERVICE_ENVIRONMENT: ${SKILLHUB_SERVICE_ENVIRONMENT:-production}
SKILLHUB_TRACING_SAMPLING_PROBABILITY: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:-0.1}
MANAGEMENT_OTLP_TRACING_ENDPOINT: ${MANAGEMENT_OTLP_TRACING_ENDPOINT:-}
SKILLHUB_OTLP_TIMEOUT: ${SKILLHUB_OTLP_TIMEOUT:-5s}
SKILLHUB_OTLP_COMPRESSION: ${SKILLHUB_OTLP_COMPRESSION:-gzip}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}

View file

@ -320,8 +320,132 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `
| 维度 | 方案 |
|------|------|
| 健康检查 | `web/nginx-health``server/actuator/health` |
| 日志 | 容器 stdout / stderr |
| 指标 | Spring Boot Actuator后续可接 Prometheus |
| 请求关联 | 响应头和日志中的 `X-Request-Id` / `request.id` |
| 日志 | 文本或 ECS 风格 JSON均输出到容器 stdout / stderr |
| Trace | `none`、Micrometer + OTel SDK、或外部 Java Agent 三选一 |
| 指标 | Spring Boot ActuatorPrometheus 是可选后端,不是 Trace 前置条件 |
### 10.1 通用配置
默认配置不要求 Collector、SkyWalking 或 Elasticsearch
```dotenv
SKILLHUB_TRACING_MODE=none
SKILLHUB_LOG_FORMAT=text
SKILLHUB_SERVICE_VERSION=v0.2.15
SKILLHUB_SERVICE_ENVIRONMENT=production
```
部署环境建议将 `SKILLHUB_LOG_FORMAT` 设为 `json`,由 Filebeat、Fluent Bit 或容器平台
采集 stdout。SkillHub 不直接连接 Elasticsearch。JSON 日志使用以下稳定字段:
- `request.id`SkillHub 请求、响应和审计关联 ID。
- `trace.id``span.id`:当前存在有效 Trace 时输出。
- `service.name``service.version``service.environment`
`SKILLHUB_LOG_ASYNC_QUEUE_SIZE` 默认是 `1024`。JSON 日志队列是有界且非阻塞的;采集端
阻塞时允许丢弃日志以保护业务线程,数据库中的 `audit_log` 仍是审计事实来源。
### 10.2 三种 Tracing 模式
三种模式只能选择一种,切换后需要重启:
| 模式 | 适用场景 | 必需配置 |
|------|----------|----------|
| `none` | 不部署链路追踪 | `SKILLHUB_TRACING_MODE=none` |
| `otel-sdk` | 厂商中立 OTLP/Collector | 模式、采样率;需要导出时再配置 endpoint |
| `external-agent` | 使用 SkyWalking Agent 原生能力 | 模式、唯一的外部 Agent不得配置 OTLP endpoint |
OTel SDK 模式的最小配置:
```dotenv
SKILLHUB_TRACING_MODE=otel-sdk
SKILLHUB_LOG_FORMAT=json
SKILLHUB_TRACING_SAMPLING_PROBABILITY=0.1
MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces
SKILLHUB_OTLP_TIMEOUT=5s
SKILLHUB_OTLP_COMPRESSION=gzip
```
未设置 `MANAGEMENT_OTLP_TRACING_ENDPOINT` 时,`otel-sdk` 仍可建立进程内 Trace但不会
创建 OTLP Exporter也不会尝试连接默认地址。`none``external-agent` 模式配置
endpoint 会启动失败。
External Agent 模式的应用侧配置:
```dotenv
SKILLHUB_TRACING_MODE=external-agent
SKILLHUB_LOG_FORMAT=json
```
部署平台还必须通过 JVM 启动参数挂载且只挂载一个 Agent。SkillHub 无法可靠识别任意
Java Agent因此上线前应检查实际 `JAVA_TOOL_OPTIONS` 或容器启动命令,确认没有同时启用
OTel Agent、SkyWalking Agent 和应用内 `otel-sdk`。SkyWalking Agent 模式可以通过官方
Logback Toolkit 输出 `trace.id``span.id` 是否可用取决于 Agent 版本。
### 10.3 OTel Collector 接入 SkyWalking
下面是只转发 Trace 的最小 Collector 配置:
```yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
otlp/skywalking:
endpoint: skywalking-oap:11800
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/skywalking]
```
SkyWalking OAP 10.3 还需要启用 OTLP Trace handler、Zipkin receiver 和 Zipkin query
```dotenv
SW_OTEL_RECEIVER_ENABLED_HANDLERS=otlp-traces
SW_RECEIVER_ZIPKIN=default
SW_QUERY_ZIPKIN=default
```
应用使用 Collector 的 OTLP/HTTP `4318` 端口Collector 使用 OAP 的 OTLP/gRPC
`11800` 端口。生产环境应按网络边界配置 TLS上例中的 `insecure: true` 只适用于受控的
容器内部网络。
SkyWalking 10.3 会把 OTLP Trace 转换为 Zipkin Trace并通过 Zipkin Query/Lens 查询。
这条路径不提供 SkyWalking Java Agent 的完整原生拓扑、慢 SQL 和 Profiling 能力。需要
这些能力时使用 `external-agent`,不要同时启用 `otel-sdk`
### 10.4 日志与 Trace 联查
JSON 日志由采集器写入 Elasticsearch 后,在 Kibana 通过 `trace.id` 查询;同一个
`trace.id` 可在 SkyWalking 的 Zipkin Query/Lens 或 Agent 原生查询界面中定位调用链。
`request.id` 始终可以用于 SkillHub 内部日志和审计关联。
当采样率小于 `1.0` 时,日志仍是全量输出,因此部分日志虽有请求关联信息,但在
SkyWalking 中没有被保留的 Trace。这是头部采样的预期行为。
### 10.5 回滚
遇到观测后端异常时:
1. 将 `SKILLHUB_TRACING_MODE` 改为 `none`
2. 删除 `MANAGEMENT_OTLP_TRACING_ENDPOINT`
3. 需要进一步降低日志开销时,将 `SKILLHUB_LOG_FORMAT` 改为 `text`
4. 滚动重启 Server。
关闭 Trace 和 JSON 日志不会改变请求、数据库或异步任务的业务语义。
## 11 安全扫描服务

View file

@ -0,0 +1,523 @@
# SkillHub 日志关联与链路追踪建设方案
> 日期2026-07-31
>
> 状态Accepted2026-07-31按本文分阶段实施和验证
>
> 关联GitHub Issue #597
> 适用基线Spring Boot 3.2.3、Java 21、Logback、Micrometer Actuator
## 1. 背景
SkillHub 已经使用 `X-Request-Id` 关联 API 响应、业务日志和审计记录,但目前仍存在以下问题:
- 部分应用服务和 DTO 直接读取 SLF4J MDC可观测性实现泄漏到了业务代码。
- `X-Request-Id` 接受任意客户端输入,没有统一的长度和字符约束。
- `@Async` 线程池没有显式传播请求和 Trace 上下文,异步日志可能丢失关联信息。
- 当前没有标准分布式 Trace无法通过一个 ID 串联 SkillHub、Scanner 等服务调用。
- 日志字段尚未形成适合 Elasticsearch/Kibana 查询的稳定结构。
本方案用最小建设成本建立通用日志关联与链路追踪基础设施。它不负责建设完整的企业
可观测性平台也不把日志、Trace 或 Metrics 逻辑写入业务处理器。
Issue #597 中“搜索索引可靠异步交付”应作为独立问题处理,不属于本文范围。
## 2. 建设目标
一期需要实现:
1. 每个 HTTP 请求都有合法的 `request.id`
2. 启用 Tracing 时,日志包含标准 `trace.id``span.id`
3. `otel-sdk` 模式使用 W3C `traceparent` / `tracestate` 传播 Trace Context。
4. 业务代码不直接读写 MDC也不直接依赖 OpenTelemetry 或 SkyWalking API。
5. 现有 Spring `@Async` 执行器能够正确传播并清理上下文。
6. 日志以结构化 JSON 输出到 stdout可由 Filebeat/Fluent Bit 采集到
Elasticsearch/Kibana。
7. Trace 可以选择通过 OTLP Collector 接入 SkyWalking。
8. Collector、SkyWalking、Elasticsearch 或日志采集器不可用时SkillHub 业务继续运行。
9. 同一进程只能有一个实际生效的 Tracer。
本方案按多个小阶段、小提交实施和验证,全部通过后再统一创建一个替代 PR。
## 3. 非目标
一期不建设:
- 搜索索引可靠队列、重试、死信和重放。
- 多租户差异化采样和运行时动态采样。
- Spring Cloud Config、Nacos 或可写 Actuator 配置端点。
- 应用内 OTLP 熔断器或自定义重试框架。
- 审计日志归档、物理隔离和 WORM 存储。
- 通用 PII/DLP 检测平台。
- Prometheus/Grafana/Kibana 告警模板和容量规划平台。
- Spring Boot 2.x 或 Java 17 兼容。
- 在业务类上增加 Trace 注解或要求业务开发者操作 Span。
## 4. 总体架构
```text
HTTP request
├─ RequestIdFilter
│ └─ request.id
└─ Micrometer Observation / Tracing
├─ MDC correlation
│ └─ JSON stdout
│ └─ Filebeat / Fluent Bit
│ └─ Elasticsearch / Kibana
└─ OpenTelemetry Bridge
└─ OTLP
└─ OpenTelemetry Collector
└─ SkyWalking OAP
```
稳定边界是:
- 应用内使用 Micrometer Observation/Tracing。
- `otel-sdk` 模式跨进程使用 W3C Trace Context。
- Trace 导出使用 OTLP。
- 日志使用 ECS 风格字段。
- SkyWalking、Elasticsearch 和 Kibana 都是部署适配器,不进入业务模型。
## 5. 运行模式
通过一个启动期配置选择运行模式:
```yaml
skillhub:
observability:
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
```
允许值和确定行为:
| 模式 | Micrometer Tracer | OTLP Exporter | 外部 Agent | 无 Agent/endpoint 时 |
|------|-------------------|---------------|------------|---------------------|
| `none` | NOOP | 无 | 不支持 | 只有 `request.id` |
| `otel-sdk` | OTel Bridge | 配置 endpoint 时创建 | 不支持 | 仍建立进程内 Trace但不导出 |
| `external-agent` | NOOP | 无 | 可选 | 记录警告并退化为只有 `request.id` |
运行模式是启动期不变量,不支持热切换。
必须保证:
- `none``external-agent` 不创建应用内 OTel Span。
- `otel-sdk` 不支持同时启用 SkyWalking、OTel 或其他外部 Tracing Agent。
- `external-agent` 不创建 OTLP Exporter。
- SkillHub 配置能够识别的冲突应在启动时失败;任意 Java Agent 无法被应用可靠识别,因此
部署检查和原型测试还必须验证实际 JVM 参数中只有一个 Tracer。
一期实现并验证三种模式的互斥边界和日志关联。`external-agent` 只验证 SkyWalking Agent
接管 Trace 后不会与应用内 OTel Tracer 冲突SkyWalking 特有高级能力不进入 SkillHub
核心代码。
## 6. 关联字段契约
### 6.1 对外日志字段
日志输出统一使用:
| 字段 | 必需性 | 含义 |
|------|--------|------|
| `request.id` | HTTP 请求或显式任务上下文中存在 | SkillHub API、响应和审计关联 ID |
| `trace.id` | 当前存在有效 Trace 时 | 分布式 Trace ID |
| `span.id` | 当前 Tracer 能提供时 | 当前调用节点 ID |
| `service.name` | 始终存在 | 固定为 `skillhub` |
| `service.version` | 部署时提供 | 发布版本或镜像对应 Commit |
| `service.environment` | 部署时提供 | 当前部署环境 |
`request.id``trace.id` 不能合并:
- `request.id` 属于 SkillHub API 契约,可出现在响应和审计记录中。
- `trace.id` 属于可选的分布式追踪上下文,可能被采样或关闭。
启动日志以及没有显式任务上下文的后台维护日志允许不包含 `request.id`
### 6.2 内部字段映射
日志基础设施负责字段映射,业务代码不感知具体 MDC 键:
| 来源 | 内部字段 | 输出字段 |
|------|----------|----------|
| SkillHub Request Context | `requestId` | `request.id` |
| Micrometer OTel Bridge | `traceId` | `trace.id` |
| Micrometer OTel Bridge | `spanId` | `span.id` |
| SkyWalking Logback Toolkit 事件转换器 | `tid` | `trace.id` |
SkyWalking Agent 是否能稳定提供独立 `span.id` 以实际原型结果为准。无法稳定提供时允许只
输出 `trace.id`,不得解析不稳定的内部字符串格式。
External Agent 模式通过 SkyWalking 官方 Logback Toolkit 从当前日志事件读取 `tid`
这不是业务代码读取 MDC也不能假定 `tid` 一定存在于异步日志线程的 MDC 中。日志编码器
只读取允许的关联字段,不得把整个 MDC Map 自动写入 JSON。
## 7. Request ID
### 7.1 输入规则
客户端可以传入 `X-Request-Id`,但必须同时满足:
- 长度为 164 个字符。
- 首字符是字母或数字。
- 其余字符只允许字母、数字、`.``_``:``-`
建议校验表达式:
```regex
^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$
```
请求头缺失、为空或不合法时,服务端生成 UUID。响应始终返回最终采用的
`X-Request-Id`
### 7.2 代码边界
新增通用 `RequestIdAccessor` 和对应的 Request ID Scope
- Filter 负责解析、校验、建立和清理 Request ID 上下文。
- 独立 ThreadLocal Scope 是 Request ID 的进程内权威来源。
- 为该 Scope 注册 Micrometer `ThreadLocalAccessor`,由
`ContextPropagatingTaskDecorator` 捕获、恢复和清理。
- Scope 同步维护日志所需的 MDC 镜像,但读取方不能把 MDC 当作权威来源。
- API 响应工厂通过该抽象读取 Request ID。
- 审计编排通过该抽象或明确参数读取 Request ID。
- 应用服务、Controller 和 DTO 不再直接调用 `MDC.get()`
- MDC 只作为日志适配器,不再作为业务上下文的权威来源。
## 8. Tracing 配置
`skillhub-app` 使用 Spring Boot 3.2.3 管理的依赖版本:
```xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
```
基础配置:
```yaml
management:
tracing:
sampling:
probability: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:0.1}
baggage:
enabled: false
propagation:
type: W3C
otlp:
tracing:
timeout: ${SKILLHUB_OTLP_TIMEOUT:5s}
compression: ${SKILLHUB_OTLP_COMPRESSION:gzip}
```
基础配置不得为 OTLP endpoint 提供默认地址。只有 `otel-sdk` 部署显式设置以下标准
Spring Boot 配置时才创建 Exporter
```bash
MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces
```
一期沿用 OpenTelemetry 1.31 的默认 BatchSpanProcessor 有界队列和丢弃策略,不增加应用内
重试、熔断或自定义队列实现。
## 9. 日志输出
### 9.1 输出模式
- 本地开发默认使用可读的文本日志。
- `SKILLHUB_LOG_FORMAT=json` 启用 ECS 风格 JSON stdout。
- JSON 编码器显式输出标准字段和三个关联字段,不启用“输出全部 MDC”。
- JSON ConsoleAppender 外包一层 Logback AsyncAppender初始队列容量为 1024并允许通过
`SKILLHUB_LOG_ASYNC_QUEUE_SIZE` 调整。
- AsyncAppender 使用非阻塞策略;队列耗尽时日志可能丢失,审计事实不依赖该通道。
- 异常使用 `error.type``error.message``error.stack_trace`
- 队列容量保持可配置,默认值在原型压测后固定,不在设计阶段猜测。
- 异常和队列丢弃行为必须在测试中验证。
示例:
```json
{
"@timestamp": "2026-07-31T10:10:10.123Z",
"log.level": "INFO",
"service.name": "skillhub",
"service.version": "0.2.15",
"service.environment": "test",
"request.id": "req-123",
"trace.id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span.id": "00f067aa0ba902b7",
"log.logger": "com.iflytek.skillhub...",
"message": "..."
}
```
应用只输出 stdout不直接依赖 Elasticsearch SDK也不直接写 Elasticsearch。
### 9.2 审计边界
`audit_log` 数据库记录仍是审计事实来源。stdout 日志不能代替审计记录,审计留存和归档
不在本方案中处理。
## 10. 上下文传播
### 10.1 Spring 异步执行器
为现有 `skillhubEventExecutor` 配置 Spring Framework 6.1 的
`ContextPropagatingTaskDecorator`
- 提交任务时捕获 Request ID 和 Trace Context。
- 执行任务时恢复上下文。
- 执行完成后在 `finally` 中清理。
- `CallerRunsPolicy` 触发时也必须保持正确的嵌套作用域。
测试必须重复复用同一工作线程,证明不同请求之间不会串号。
### 10.2 长生命周期后台线程
Redis Stream 消费循环、Reclaimer 和其他长生命周期线程不继承应用启动线程或任意请求的
MDC。需要追踪具体任务时由通用任务执行边界建立新的上下文。
一期不把 HTTP Trace Context 写入搜索业务 payload也不改造可靠任务状态机。
### 10.3 HTTP 出站
一期只管理两类 HTTP Client
- 内部 Scanner Client使用 Spring 管理且带 Observation 的 Builder传播 W3C Trace
Context。
- 其他现有 ClientGitHub、GitLab、内置 Skill 公网下载和 S3 Client 均不在一期新增
Trace Context 传播。
后续新增 Client 必须明确选择内部或外部配置,不能依赖全局 Host 正则或在业务代码中手工
删除 Header。
## 11. SkyWalking 与 Elasticsearch 接入
### 11.1 OTel SDK 模式
推荐链路:
```text
SkillHub
→ OTLP/HTTP
→ OpenTelemetry Collector
→ OTLP
→ SkyWalking OAP
```
Collector 用于协议适配和后端路由,不是 SkillHub 的启动依赖。
SkyWalking 10.3 的 OTLP Trace 会转换为 Zipkin Trace并通过 Zipkin Query/Lens UI 查询。
它不等价于 SkyWalking Java Agent 的原生拓扑、慢 SQL 和 Profiling 能力,部署文档必须
明确该差异。原型报告必须记录实际使用的 Maven 依赖、Collector、OAP 和 Agent 版本及
查询结果。
### 11.2 External Agent 模式
需要 SkyWalking 原生能力时:
- 使用 `external-agent`
- 不配置 SkillHub OTLP endpoint。
- 由部署环境挂载并启动 SkyWalking Java Agent。
- 使用 SkyWalking 官方 Logback Toolkit 提供 Trace ID。
- 日志基础设施将 `tid` 映射为 `trace.id`
### 11.3 日志链路
```text
SkillHub JSON stdout
→ Filebeat / Fluent Bit
→ Elasticsearch
→ Kibana
```
Kibana 使用 `trace.id` 查询日志SkyWalking 使用同一个 Trace ID 查询调用链。
## 12. 实施步骤
### 阶段一Request ID 与日志边界
1. 增加 Request ID 校验。
2. 建立 `RequestIdAccessor`
3. 移除应用服务、Controller、DTO 对 MDC 的直接读取。
4. 增加允许字段明确的结构化日志配置。
5. 增加 Request ID 和日志字段测试。
可观察结果:
- 非法 Request ID 被替换。
- API 响应和审计记录仍使用同一 Request ID。
- 业务类不再 import `org.slf4j.MDC`
### 阶段二Micrometer + OTel
1. 增加 Tracing Bridge 和 OTLP Exporter 依赖。
2. 增加 `none``otel-sdk``external-agent` 模式。
3. 设置 W3C、关闭 baggage、配置采样率。
4. 保证无 endpoint 时不会产生网络连接。
5. 保证每个模式只存在一个实际 Tracer。
可观察结果:
- `none` 模式只有 `request.id`
- `otel-sdk` 模式日志出现标准 Trace 字段。
- `external-agent` 模式不会产生应用内 OTel Trace。
### 阶段三:传播边界
1. 为 `skillhubEventExecutor` 增加上下文传播。
2. 验证线程复用、嵌套任务和 `CallerRunsPolicy`
3. 让内部 Scanner Client 使用 Spring 管理且可观测的 Client Builder。
4. 验证外部 HTTP Client 不发送 Trace Context。
### 阶段四:部署示例与远端验证
1. 提供最小 OTel Collector 配置示例。
2. 补充 SkyWalking OTLP 与 Agent 模式差异。
3. 将待测分支合入 `big-main`,记录合入后的精确 Commit SHA。
4. 构建绑定 `big-main` SHA 的测试镜像。
5. 在共享测试机使用独立容器、网络、数据卷和动态端口运行三个原型。
6. 生成中文测试报告并保存在本地私有目录,不提交开源仓库。
每个阶段使用独立的小提交并保留在同一实现分支;前一阶段的范围测试通过后再进入下一
阶段。公开 Issue 和 PR 统一在阶段五创建。
### 阶段五:社区交付(最后执行)
该阶段必须在远端验证全部通过后执行:
1. 创建新的可观测性建设 Issue说明它承接 #597 中的“通用日志关联与链路追踪”部分。
2. 搜索索引可靠异步交付继续作为独立问题,不混入新的可观测性 Issue。
3. 从经过验证的实现分支创建新的 PR并关联新 Issue。
4. PR 只包含公开代码、配置、自动化测试和公开部署说明;不得包含测试机地址、凭证、
私有端口、原始远端日志或本地中文测试报告。
5. 在 #597#644 及其他被替代的关联项中回复:
- 原问题是否真实存在。
- 为什么不采用原 PR 的实现。
- 新方案的边界和主要改动。
- 已完成的自动化及远端验证摘要。
- 新 Issue 和替代 PR 的链接。
6. 确认维护者需要的信息完整后,关闭已被替代的 PR不在验证完成前抢先关闭。
7. #597 等关联 Issue 只根据剩余问题是否已有明确承接决定关闭、缩小范围或继续保留,
不因替代 PR 创建而自动关闭。
8. 新 PR 通过 Review 和 CI 后,确认 PR Head 仍等于已验证的功能 SHA且该 SHA 可从已
测试的 `big-main` SHA 到达;满足后才允许更新 `main`
9. 如果 Review 或 CI 修复改变了代码、配置或测试脚本,则原验证证据失效:先将新 SHA
合入 `big-main`,重新构建镜像并完成受影响的远端验证,再更新 `main`
## 13. 验证方案
### 13.1 自动化测试
至少覆盖:
- 未传 Request ID 时自动生成。
- 合法 Request ID 被保留。
- 空值、超长值和非法字符被替换。
- Filter 正常、异常退出后都清理上下文。
- API 响应、审计和日志中的 Request ID 一致。
- JSON 只输出允许的关联字段。
- Trace 采样率在测试中设为 `1.0` 后可稳定断言。
- `@Async` 线程恢复父上下文。
- 连续复用同一线程执行不同请求时不串号。
- `CallerRunsPolicy` 下上下文正确恢复。
- `none``otel-sdk``external-agent` 的 Spring Context 互斥。
- 未配置 OTLP endpoint 时不创建网络导出。
- 内部 Scanner 请求携带 `traceparent`
- 外部 HTTP 请求不携带 `traceparent`
### 13.2 远端原型
#### 原型 Anone
- 不部署 Collector。
- SkillHub 正常启动并完成核心 Smoke Test。
- 日志存在 `request.id`,不存在伪造的 Trace 字段。
#### 原型 Botel-sdk
- SkillHub → Collector → SkyWalking 跑通。
- JSON 日志进入 Elasticsearch/Kibana。
- Kibana 与 SkyWalking 能用同一 `trace.id` 查询。
- Collector 停止后 SkillHub API 和异步任务继续工作。
#### 原型 Cexternal-agent
- SkyWalking Java Agent 提供原生 Trace。
- 应用内 OTel Exporter 不工作。
- 日志能用 SkyWalking Trace ID 关联。
- 不产生双 Trace、重复 Span 或两个冲突的 Trace ID。
### 13.3 远端测试场景
- HTTP 成功、4xx、5xx 和未认证请求。
- Scanner 成功、超时和失败。
- 异步事件正常执行和抛出异常。
- 并发请求重复使用线程池。
- Collector 启动、停止和恢复。
- 日志采集器停止或消费变慢。
- 采样率 `0.0``0.1``1.0`
- 容器收到 SIGTERM 后日志和 Trace 的关闭行为。
- 日志中不出现 Authorization、Cookie、Token、密码和完整请求体。
## 14. 验收标准
以下条件全部满足后,一期才算完成:
- [ ] 三种模式行为与本文一致。
- [ ] 业务代码不再直接读取或写入 MDC。
- [ ] Request ID 校验、响应和审计关联测试通过。
- [ ] 日志字段符合约定,且不输出完整 MDC。
- [ ] Spring 异步执行器上下文传播和隔离测试通过。
- [ ] 内外部 HTTP 传播边界测试通过。
- [ ] 无 OTLP endpoint 时不存在外部连接尝试。
- [ ] Collector 中断不影响 SkillHub 业务结果。
- [ ] OTel SDK 与 SkyWalking Agent 不会同时产生 Trace。
- [ ] `make test-backend-app` 通过。
- [ ] `make typecheck-web``make lint-web` 通过。
- [ ] 基于 `big-main` 合入后精确 SHA 构建的远端三个原型通过。
- [ ] 中文测试报告保存在本地私有目录。
- [ ] 新的可观测性 Issue 和替代 PR 已创建并互相关联。
- [ ] #597#644 等关联项已获得清晰回复,被替代的旧 PR 已关闭。
- [ ] 关联 Issue 已根据剩余范围分别关闭、缩小范围或保留,且状态理由清楚。
- [ ] 新 PR Head 与已验证功能 SHA 一致,且可从已测试的 `big-main` SHA 到达。
- [ ] 通过验证后才允许更新 `main`
## 15. 回滚
出现问题时:
1. 将 `SKILLHUB_TRACING_MODE` 改为 `none`
2. 删除 `MANAGEMENT_OTLP_TRACING_ENDPOINT`
3. 将 `SKILLHUB_LOG_FORMAT` 改为 `text`
4. 保留 Request ID 和原有文本日志能力。
5. 通过滚动重启恢复,不进行运行时模式切换。
Tracing 和结构化日志关闭后不得影响 SkillHub 的业务状态、数据库状态或任务执行语义。
## 16. 已知限制
- 10% Head Sampling 下,全量日志中的部分 `trace.id` 在 SkyWalking 中没有对应 Trace。
- SkyWalking OTLP 模式的展示能力弱于原生 Java Agent。
- 日志队列在背压时可能丢弃日志,这是保护业务线程的预期行为。
- External Agent 提供哪些 MDC 字段取决于具体 Agent 和版本。
- 一期只处理通用关联和传播,不保证搜索索引异步交付可靠性。
## 17. 参考资料
- [Spring Boot 3.2.3 Tracing](https://docs.spring.io/spring-boot/docs/3.2.3/reference/html/actuator.html#actuator.micrometer-tracing)
- [Micrometer Tracing](https://docs.micrometer.io/tracing/reference/)
- [OpenTelemetry Java OTLP Exporter](https://opentelemetry.io/docs/languages/java/exporters/)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
- [SkyWalking OpenTelemetry Trace](https://skywalking.apache.org/docs/main/v10.3.0/en/setup/backend/otlp-trace/)
- [SkyWalking Logback Toolkit](https://skywalking.apache.org/docs/skywalking-java/next/en/setup/service-agent/java-agent/application-toolkit-logback-1.x/)
- [Elastic ECS Tracing Fields](https://www.elastic.co/docs/reference/ecs/ecs-tracing)
- [方案调研](./research/2026-07-31-observability-common-solutions.md)

View file

@ -0,0 +1,124 @@
# 通用可观测性决策图
目标:为 SkillHub 建立独立、通用、可插拔的日志关联、指标和链路追踪基础设施。
它观察 HTTP、线程池、定时任务和可靠任务等执行边界但不进入业务模型和业务载荷。
边界:
- Servlet Filter、执行器装饰器、调度拦截器和任务执行拦截器负责建立/恢复上下文。
- 业务代码不读写 MDC不负责创建通用 Span也不负责统计任务生命周期指标。
- 使用 W3C Trace Context日志后端、Metrics 后端和 Trace Exporter 均可替换。
- 上下文是有长度限制的基础设施元数据,不进入业务 payload。
- Collector、Exporter 或 Metrics 后端不可用时,主业务和任务内核继续工作。
必须满足的不变量:
- 每个执行边界都正确建立作用域并在 `finally` 清理,线程复用不得串号。
- 日志稳定输出 `requestId``traceId``spanId`;任务执行时额外输出执行资源标识。
- Trace 与 Metrics 可关闭、可替换;关闭后不得改变业务行为。
- 指标只使用低基数维度,业务 ID 不进入标签。
- 采集端不可用必须异步、限时、限队列并 fail-open。
## #1:可观测性是否与业务和任务状态机彻底分离?
Blocked by: 无
Type: Grilling
### Question
可观测性是否只通过通用执行边界和生命周期信号接入,不进入业务处理器?
### Answer
已确认。可靠任务内核只发布通用生命周期信号;可观测性拦截器把执行资源标识加入日志、
Span 和指标。搜索处理器只处理搜索,不认识 MDC、OpenTelemetry 或 Prometheus。
## #2:通用关联身份和传播协议是什么?
Blocked by: #1
Type: Research
### Question
如何区分现有 `X-Request-Id`、W3C `traceId/spanId` 和执行资源标识,并跨 HTTP、线程池、
调度器和持久化任务边界传播?
### Answer
已确定:
- `requestId` 是 SkillHub 的请求/审计关联标识,不冒充分布式 Trace。
- `traceId/spanId` 由 Tracer 生成,跨进程只使用 W3C `traceparent/tracestate`
- 定时任务或可靠任务的执行资源 ID 只作为当前执行作用域属性,不进入业务 payload。
- HTTP、线程池、调度器和持久化 carrier 的注入/提取全部位于基础设施拦截器。
- 不传播任意 MDC Mapbaggage 默认关闭,任何允许项都必须低敏、限长、显式配置。
- 无效或不可信的公网 Trace Context 按 W3C 规则丢弃,服务端控制采样。
常见方案和候选组合见
[Java / Spring 通用日志关联与链路追踪方案调研](./research/2026-07-31-observability-common-solutions.md)。
## #3:采用 Micrometer Observation、OpenTelemetry API/SDK 还是 Java Agent
Blocked by: #2
Type: Research
### Question
哪种组合最适配 Spring Boot 3.2.3,并同时支持无 Collector 运行、可选 OTLP 和稳定日志关联?
### Answer
已选择三模式:
- `none`:不创建应用内 OTel SDK 或 Exporter只保留 Request ID。
- `otel-sdk`:使用 Micrometer Tracing + OTel Bridge配置 OTLP endpoint 时才导出。
- `external-agent`:应用内使用 NOOP Tracer由部署环境提供唯一的外部 Agent。
应用代码只依赖 Micrometer/Observation 边界,不依赖 OTel SDK 或 SkyWalking API。
自动配置测试已证明三种模式互斥,错误的 endpoint/mode 组合会在启动时失败。
## #4:如何证明上下文传播、日志输出和故障降级正确?
Blocked by: #3
Type: Prototype
### Question
验证线程复用隔离、嵌套作用域、异步/调度/持久化任务边界、采样、Exporter 超时、
Collector 中断、队列打满和关闭观测能力等场景。
### Answer
本地原型已证明:
- Request ID Scope 在线程复用、嵌套 Scope、异常退出和 `CallerRunsPolicy` 下均能恢复并
清理。
- Micrometer 手工 Span 和 Observation 均能随 `skillhubEventExecutor` 传播。
- Scanner 使用 Spring 管理的 `WebClient.Builder` 传播 W3C `traceparent`
- 面向用户配置的 GitLab 外部 Client 不传播 Trace Context。
- `none / otel-sdk / external-agent` 的应用上下文和 Exporter 条件符合设计。
Collector 中断、日志背压、采样率和关闭行为仍由 `big-main` 精确 SHA 镜像的远端原型验证。
## #5:如何形成可部署闭环?
Blocked by: #4
Type: Research
### Question
确定 stdout 格式、可选 JSON、Prometheus 或 OTLP Metrics、Trace Exporter、暴露边界、
低基数告警和运维文档。
### Answer
已确定最小交付:
- 文本日志用于本地开发ECS 风格 JSON stdout 用于部署环境。
- JSON 日志只输出白名单关联字段,通过有界非阻塞 AsyncAppender 保护业务线程。
- Trace 可经 OTLP Collector 路由到 SkyWalking需要 SkyWalking 原生能力时改用唯一的
Java Agent。
- Prometheus 继续作为可选 Metrics 后端,不是本期链路关联的前置条件。
部署配置和三模式操作说明写入 `docs/09-deployment.md`;远端实测结果只保存在本地私有
中文报告中。

View file

@ -0,0 +1,120 @@
# Java / Spring 通用日志关联与链路追踪方案调研
调研时间2026-07-31
适用基线SkillHubSpring Boot 3.2.3、Java 21、Logback、Micrometer Actuator
## 结论
当前 Java/Spring 生态已经基本收敛到以下组合:
1. 使用 W3C `traceparent` / `tracestate` 作为跨进程传播协议。
2. Spring 应用内使用 Micrometer Observation/Tracing底层桥接 OpenTelemetry。
3. 云原生或需要广覆盖自动插桩时使用 OpenTelemetry Java Agent。
4. 使用 OTLP 把 Trace 发往 Collector再由 Collector 路由到 Tempo、Jaeger、Zipkin、
SkyWalking 或商业后端。
5. 日志只消费当前上下文中的 `traceId` / `spanId`,业务代码不操作 MDC。
Spring Cloud Sleuth、手写 MDC/TID、TLog/TTL 和厂商 Agent 仍能见到,但不应作为
SkillHub 新机制的协议核心。
## 常见方案比较
| 方案 | 常见使用场景 | 优点 | 主要缺口 |
|---|---|---|---|
| Filter + MDC + TaskDecorator | 单体应用、只要求按 ID 查日志 | 简单、无采集端 | 没有真实 Span容易漏线程/客户端边界;手写传播易串号 |
| Micrometer Tracing + OTel bridge | Spring Boot 3.x 应用内建观测 | Spring 官方路径;自动日志关联;便于自定义基础设施 Observation | 覆盖依赖 Spring 已观测的组件;线程池仍要正确配置上下文传播 |
| OpenTelemetry Java Agent | Kubernetes、统一运维、需要 JDBC/Redis/HTTP 等广覆盖 | OTel 官方对 Spring Boot 的默认建议;零代码;覆盖面最大 | 需要部署 Agent必须实测启动/CPU/内存开销;自定义持久化任务边界仍需扩展 |
| OpenTelemetry Spring Boot Starter | Native Image、不能挂 Agent、需要应用 YAML 配置 | OTel SDK 原生集成;适合 Agent 不可用场景 | OTel 官方不把它作为普通 Spring Boot 的默认选择;需要单独管理 OTel BOM |
| SkyWalking/Elastic/Pinpoint 等 Agent | 已统一采购或部署特定 APM 的企业 | 自动插桩成熟、开箱 UI | 协议和后端绑定更强;不适合作为开源产品内部 API |
| Spring Cloud Sleuth | Spring Boot 2.x 历史项目 | 旧生态成熟 | 官方明确不支持 Spring Boot 3.x核心已迁移到 Micrometer Tracing |
## 官方事实
### Spring Boot
- Spring Boot 3.2.3 Actuator 为 Micrometer Tracing 提供依赖管理和自动配置。
- OTel 组合使用 `micrometer-tracing-bridge-otel`OTLP 使用
`opentelemetry-exporter-otlp`
- 启用 Micrometer Tracing 后Spring Boot 默认把 `traceId``spanId` 放入 MDC
支持通过 `logging.pattern.correlation` 固定日志格式。
- Spring Boot 3.2.3 默认产生 W3C 上下文,并可消费 W3C、B3、B3 Multi新设计应只
产生 W3C兼容消费策略可单独配置。
- 自动 HTTP 传播依赖 Spring 自动配置的 HTTP Client Builder自行 `new` 客户端会
绕过传播。
- Spring Framework 6.1 提供 `ContextPropagatingTaskDecorator`,用于恢复日志和
Observation 上下文;官方同时提醒大量极小任务会有传播开销。
### OpenTelemetry
- OTel 官方把 Java Agent 列为普通 Spring Boot 应用的默认零代码方案,因为它比
Spring Boot Starter 提供更多开箱插桩。
- Starter 主要面向 Native Image、Agent 启动开销不满足要求、已有其他 Java Agent
或需要通过 Spring 配置文件管理 OTel 的场景。
- Java Agent 覆盖 Spring Web MVC、JDBC、Lettuce、Java Executors、Logback 等
SkillHub 关键边界。
- Agent 的 Logback MDC 默认键为 `trace_id``span_id``trace_flags`Micrometer
默认键为 `traceId``spanId`。若支持两种运行模式,必须统一日志字段,不能让查询方
感知两套命名。
- OTel 官方要求在目标部署环境实测 Agent 开销,没有通用的固定开销数字;采样率、
JDBC/Redis Span 数量和资源限制都会影响结果。
### W3C Trace Context
- `traceparent` / `tracestate` 是厂商中立的传播协议。
- Header 必须按标准校验;无效上下文应丢弃并创建新 Trace。
- Trace Context 不得携带用户身份、IP、Token 或其他敏感信息。
- 公网调用方可伪造 sampled 标志,因此采样和费用控制必须由服务端约束。
## 开源项目观察
- OpenTelemetry Demo 的 Java 服务直接在镜像中挂载
`opentelemetry-javaagent.jar`,通过标准 `OTEL_*` 配置连接 Collector代表
云原生 Agent 路径。
- Spring Petclinic Microservices 使用 Spring Boot tracing starter 和 Zipkin 后端,
代表 Spring 原生集成路径。后端选择不同,但应用侧仍依赖 Spring 观测抽象。
- RuoYi-Cloud-Plus 预留 SkyWalking Java Agent 和 OAP/UI代表厂商 Agent 路径;
适用于组织已统一使用 SkyWalking 的情况,不适合作为 SkillHub 的内部协议。
## 对 SkillHub 的候选结论
应用代码的稳定边界应是 Spring 的 Observation/Tracing 抽象与 W3C 协议,而不是某个
日志或 APM 产品:
```text
HTTP / Executor / Scheduler / Reliable Task boundary
Observability interceptor
Micrometer Observation / Tracing facade
OpenTelemetry bridge + W3C
optional OTLP exporter / Collector
```
候选主运行模式:
- 应用内使用 Micrometer Tracing + OpenTelemetry bridge保证 Spring Boot 3.2.3
原生整合、统一 MDC 字段和自定义基础设施 Observation。
- OTLP Exporter 默认关闭;开启后只负责异步导出,不改变请求结果。
- Java Agent 作为高级部署模式,用于获得 JDBC、Redis、第三方 HTTP Client 等更广
自动插桩。Agent 与应用内自动插桩不得同时启用,除非原型证明不会产生重复 Span。
- 无 Trace SDK/Agent 时仍保留 `requestId` 日志关联Trace 是增强能力,不是业务前置条件。
最终选择仍需原型验证:同一请求的 Span 是否重复、线程池上下文是否串号、Collector
中断是否影响延迟、日志字段是否一致、关闭 tracing 后业务行为是否完全不变。
## 参考资料
- [Spring Boot 3.2.3 Tracing](https://docs.spring.io/spring-boot/docs/3.2.3/reference/html/actuator.html#actuator.micrometer-tracing)
- [Spring Boot current Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html)
- [Spring Framework 6.1 ContextPropagatingTaskDecorator](https://docs.spring.io/spring-framework/docs/6.1.4/javadoc-api/org/springframework/core/task/support/ContextPropagatingTaskDecorator.html)
- [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/)
- [OpenTelemetry Spring Boot Starter](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/)
- [OpenTelemetry Java supported libraries](https://opentelemetry.io/docs/zero-code/java/agent/supported-libraries/)
- [OpenTelemetry Java Agent performance](https://opentelemetry.io/docs/zero-code/java/agent/performance/)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
- [Spring Cloud Sleuth end-of-line notice](https://docs.spring.io/spring-cloud-sleuth/docs/current/reference/html/)
- [OpenTelemetry Demo](https://github.com/open-telemetry/opentelemetry-demo)
- [Spring Petclinic Microservices](https://github.com/spring-petclinic/spring-petclinic-microservices)
- [RuoYi-Cloud-Plus](https://github.com/dromara/RuoYi-Cloud-Plus)

View file

@ -30,6 +30,24 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-toolkit-logback-1.x</artifactId>
<version>9.6.0</version>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

View file

@ -21,12 +21,12 @@ import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@ -49,6 +49,7 @@ public class ClawHubCompatAppService {
private final AuditLogService auditLogService;
private final CompatSkillLookupService compatSkillLookupService;
private final SkillStarService skillStarService;
private final RequestIdAccessor requestIdAccessor;
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
SkillSearchAppService skillSearchAppService,
@ -58,7 +59,8 @@ public class ClawHubCompatAppService {
MultipartPackageExtractor multipartPackageExtractor,
AuditLogService auditLogService,
CompatSkillLookupService compatSkillLookupService,
SkillStarService skillStarService) {
SkillStarService skillStarService,
RequestIdAccessor requestIdAccessor) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@ -68,6 +70,7 @@ public class ClawHubCompatAppService {
this.auditLogService = auditLogService;
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
this.requestIdAccessor = requestIdAccessor;
}
public ClawHubSearchResponse search(String q,
@ -430,7 +433,7 @@ public class ClawHubCompatAppService {
"COMPAT_PUBLISH",
"SKILL_VERSION",
versionId,
MDC.get("requestId"),
requestIdAccessor.current(),
clientIp,
userAgent,
detailJson

View file

@ -1,12 +1,10 @@
package com.iflytek.skillhub.config;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@ -21,37 +19,17 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
public class AsyncConfig {
@Bean(name = "skillhubEventExecutor")
public Executor skillhubEventExecutor() {
public Executor skillhubEventExecutor(
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator
) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("skillhub-event-");
executor.setTaskDecorator(contextPropagatingTaskDecorator);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(mdcTaskDecorator());
executor.initialize();
return executor;
}
private TaskDecorator mdcTaskDecorator() {
return task -> {
Map<String, String> callerContext = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> executorContext = MDC.getCopyOfContextMap();
try {
restoreMdc(callerContext);
task.run();
} finally {
restoreMdc(executorContext);
}
};
};
}
private void restoreMdc(Map<String, String> context) {
MDC.clear();
if (context != null) {
MDC.setContextMap(context);
}
}
}

View file

@ -26,7 +26,10 @@ public class SkillScannerConfig {
@Bean
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
public HttpClient scannerHttpClient(SkillScannerProperties properties) {
public HttpClient scannerHttpClient(
WebClient.Builder webClientBuilder,
SkillScannerProperties properties
) {
int readTimeoutMs = properties.getReadTimeoutMs();
int connectTimeoutMs = properties.getConnectTimeoutMs();
@ -52,7 +55,7 @@ public class SkillScannerConfig {
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(100 * 1024 * 1024))
.build();
WebClient webClient = WebClient.builder()
WebClient webClient = webClientBuilder.clone()
.clientConnector(new ReactorClientHttpConnector(reactorClient))
.exchangeStrategies(strategies)
.build();

View file

@ -6,8 +6,8 @@ import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -24,13 +24,16 @@ public class DeviceAuthWebController extends BaseApiController {
private final DeviceAuthService deviceAuthService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public DeviceAuthWebController(ApiResponseFactory responseFactory,
DeviceAuthService deviceAuthService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.deviceAuthService = deviceAuthService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
@PostMapping("/authorize")
@ -45,7 +48,7 @@ public class DeviceAuthWebController extends BaseApiController {
"DEVICE_AUTHORIZE",
"DEVICE_CODE",
null,
MDC.get("requestId"),
requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"userCode\":\"" + request.userCode() + "\"}"

View file

@ -21,6 +21,7 @@ import com.iflytek.skillhub.dto.UpdateProfileRequest;
import com.iflytek.skillhub.dto.UpdateProfileResponse;
import com.iflytek.skillhub.dto.UserProfileResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
@ -53,19 +54,22 @@ public class UserProfileController extends BaseApiController {
private final ProfileChangeRequestRepository changeRequestRepository;
private final PlatformSessionService platformSessionService;
private final ProfileFieldPolicyConfig fieldPolicyConfig;
private final RequestIdAccessor requestIdAccessor;
public UserProfileController(ApiResponseFactory responseFactory,
UserProfileService userProfileService,
UserAccountRepository userAccountRepository,
ProfileChangeRequestRepository changeRequestRepository,
PlatformSessionService platformSessionService,
ProfileFieldPolicyConfig fieldPolicyConfig) {
ProfileFieldPolicyConfig fieldPolicyConfig,
RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.userProfileService = userProfileService;
this.userAccountRepository = userAccountRepository;
this.changeRequestRepository = changeRequestRepository;
this.platformSessionService = platformSessionService;
this.fieldPolicyConfig = fieldPolicyConfig;
this.requestIdAccessor = requestIdAccessor;
}
/**
@ -143,7 +147,7 @@ public class UserProfileController extends BaseApiController {
UpdateProfileResult result = userProfileService.updateProfile(
principal.userId(),
changes,
httpRequest.getHeader("X-Request-Id"),
requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent")
);

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ProfileReviewMutationResponse;
import com.iflytek.skillhub.dto.ProfileReviewRejectRequest;
import com.iflytek.skillhub.dto.ProfileReviewSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.AdminProfileReviewAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@ -28,13 +29,16 @@ public class AdminProfileReviewController extends BaseApiController {
private final AdminProfileReviewAppService appService;
private final ProfileReviewService reviewService;
private final RequestIdAccessor requestIdAccessor;
public AdminProfileReviewController(ApiResponseFactory responseFactory,
AdminProfileReviewAppService appService,
ProfileReviewService reviewService) {
ProfileReviewService reviewService,
RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.appService = appService;
this.reviewService = reviewService;
this.requestIdAccessor = requestIdAccessor;
}
/** List profile change requests filtered by status (default: PENDING). */
@ -58,7 +62,7 @@ public class AdminProfileReviewController extends BaseApiController {
var result = reviewService.approve(
id,
principal.userId(),
httpRequest.getHeader("X-Request-Id"),
requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
return ok("response.success.updated",
@ -77,7 +81,7 @@ public class AdminProfileReviewController extends BaseApiController {
id,
principal.userId(),
request.comment(),
httpRequest.getHeader("X-Request-Id"),
requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
return ok("response.success.updated",

View file

@ -5,8 +5,8 @@ import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import com.iflytek.skillhub.search.SearchRebuildService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -23,13 +23,16 @@ public class AdminSearchController extends BaseApiController {
private final SearchRebuildService searchRebuildService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public AdminSearchController(ApiResponseFactory responseFactory,
SearchRebuildService searchRebuildService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.searchRebuildService = searchRebuildService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
@PostMapping("/rebuild")
@ -42,7 +45,7 @@ public class AdminSearchController extends BaseApiController {
"REBUILD_SEARCH_INDEX",
"SEARCH_INDEX",
null,
MDC.get("requestId"),
requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"scope\":\"ALL\"}"

View file

@ -1,8 +1,8 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import java.time.Clock;
@ -13,24 +13,28 @@ public class ApiResponseFactory {
private final MessageSource messageSource;
private final Clock clock;
private final RequestIdAccessor requestIdAccessor;
public ApiResponseFactory(MessageSource messageSource, Clock clock) {
public ApiResponseFactory(MessageSource messageSource,
Clock clock,
RequestIdAccessor requestIdAccessor) {
this.messageSource = messageSource;
this.clock = clock;
this.requestIdAccessor = requestIdAccessor;
}
public <T> ApiResponse<T> ok(String messageCode, T data, Object... args) {
String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale());
return new ApiResponse<>(0, msg, data, Instant.now(clock), MDC.get("requestId"));
return new ApiResponse<>(0, msg, data, Instant.now(clock), requestIdAccessor.current());
}
public ApiResponse<Void> error(int code, String messageCode, Object... args) {
String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale());
return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId"));
return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current());
}
public ApiResponse<Void> errorMessage(int code, String msg) {
return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId"));
return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current());
}
public IdentityLinkErrorResponse identityLinkError(
@ -48,7 +52,7 @@ public class ApiResponseFactory {
msg,
reasonCode,
Instant.now(clock),
MDC.get("requestId"));
requestIdAccessor.current());
}
public IdentityLinkErrorResponse identityLinkErrorMessage(
@ -60,6 +64,6 @@ public class ApiResponseFactory {
message,
reasonCode,
Instant.now(clock),
MDC.get("requestId"));
requestIdAccessor.current());
}
}

View file

@ -11,12 +11,12 @@ import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import com.iflytek.skillhub.storage.StorageAccessException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@ -38,13 +38,16 @@ public class GlobalExceptionHandler {
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final SkillHubMetrics metrics;
private final RequestIdAccessor requestIdAccessor;
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer,
SkillHubMetrics metrics) {
SkillHubMetrics metrics,
RequestIdAccessor requestIdAccessor) {
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.metrics = metrics;
this.requestIdAccessor = requestIdAccessor;
}
@ExceptionHandler(LocalizedException.class)
@ -192,7 +195,7 @@ public class GlobalExceptionHandler {
metrics.incrementStorageAccessFailure(ex.getOperation());
logger.warn(
"Object storage unavailable [requestId={}, method={}, path={}, userId={}, operation={}, key={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
@ -208,7 +211,7 @@ public class GlobalExceptionHandler {
public ResponseEntity<?> handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) {
String path = request.getRequestURI();
if (path != null && path.endsWith("/sse")) {
logger.debug("SSE timeout [requestId={}, path={}]", MDC.get("requestId"), path);
logger.debug("SSE timeout [requestId={}, path={}]", requestIdAccessor.current(), path);
return ResponseEntity.noContent().build();
}
@ -221,7 +224,7 @@ public class GlobalExceptionHandler {
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
@ -234,7 +237,7 @@ public class GlobalExceptionHandler {
private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
logger.info(
"API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
status.value(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
@ -34,15 +35,18 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
private final IdempotencyRecordRepository idempotencyRecordRepository;
private final ObjectMapper objectMapper;
private final Clock clock;
private final RequestIdAccessor requestIdAccessor;
public IdempotencyInterceptor(StringRedisTemplate redisTemplate,
IdempotencyRecordRepository idempotencyRecordRepository,
ObjectMapper objectMapper,
Clock clock) {
Clock clock,
RequestIdAccessor requestIdAccessor) {
this.redisTemplate = redisTemplate;
this.idempotencyRecordRepository = idempotencyRecordRepository;
this.objectMapper = objectMapper;
this.clock = clock;
this.requestIdAccessor = requestIdAccessor;
}
/**
@ -56,8 +60,8 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
return true;
}
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isEmpty()) {
String requestId = resolveRequestId(request);
if (requestId == null) {
return true;
}
@ -116,8 +120,8 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
return;
}
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isEmpty()) {
String requestId = resolveRequestId(request);
if (requestId == null) {
return;
}
@ -139,8 +143,16 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
private void writeDuplicateResponse(HttpServletResponse response) throws Exception {
ApiResponse<Void> body = new ApiResponse<>(409, "error.request.duplicate", null,
Instant.now(clock), null);
Instant.now(clock), requestIdAccessor.current());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
private String resolveRequestId(HttpServletRequest request) {
String suppliedRequestId = request.getHeader(REQUEST_ID_HEADER);
if (suppliedRequestId == null || suppliedRequestId.isEmpty()) {
return null;
}
return requestIdAccessor.current();
}
}

View file

@ -1,10 +1,10 @@
package com.iflytek.skillhub.filter;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@ -12,6 +12,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Ensures every request has a request identifier for logs, responses, and downstream audit
@ -22,23 +23,27 @@ import java.util.UUID;
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
private static final String REQUEST_ID_MDC_KEY = "requestId";
private static final Pattern VALID_REQUEST_ID =
Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
private final RequestIdAccessor requestIdAccessor;
public RequestIdFilter(RequestIdAccessor requestIdAccessor) {
this.requestIdAccessor = requestIdAccessor;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isBlank()) {
if (requestId == null || !VALID_REQUEST_ID.matcher(requestId).matches()) {
requestId = UUID.randomUUID().toString();
}
MDC.put(REQUEST_ID_MDC_KEY, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
try {
try (RequestIdAccessor.Scope ignored = requestIdAccessor.open(requestId)) {
filterChain.doFilter(request, response);
} finally {
MDC.remove(REQUEST_ID_MDC_KEY);
}
}
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.observability;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* Holds the current SkillHub request identifier independently from the logging implementation.
*
* <p>The thread-local value is authoritative. MDC is maintained only as a mirror for log
* correlation.</p>
*/
@Component
public class RequestIdAccessor {
public static final String MDC_KEY = "requestId";
private final ThreadLocal<String> currentRequestId = new ThreadLocal<>();
/**
* Returns the current request identifier, or {@code null} outside a request/task scope.
*/
public String current() {
return currentRequestId.get();
}
/**
* Opens a nested request identifier scope on the current thread.
*/
public Scope open(String requestId) {
Objects.requireNonNull(requestId, "requestId must not be null");
if (requestId.isBlank()) {
throw new IllegalArgumentException("requestId must not be blank");
}
String previousRequestId = currentRequestId.get();
replace(requestId);
return new Scope(previousRequestId, requestId);
}
void replace(String requestId) {
if (requestId == null) {
currentRequestId.remove();
MDC.remove(MDC_KEY);
return;
}
currentRequestId.set(requestId);
MDC.put(MDC_KEY, requestId);
}
/**
* A same-thread, LIFO scope for the request identifier.
*/
public final class Scope implements AutoCloseable {
private final String previousRequestId;
private final String installedRequestId;
private boolean closed;
private Scope(String previousRequestId, String installedRequestId) {
this.previousRequestId = previousRequestId;
this.installedRequestId = installedRequestId;
}
@Override
public void close() {
if (closed) {
return;
}
if (!Objects.equals(currentRequestId.get(), installedRequestId)) {
throw new IllegalStateException("Request ID scopes must close on the owning thread in LIFO order");
}
replace(previousRequestId);
closed = true;
}
}
}

View file

@ -0,0 +1,37 @@
package com.iflytek.skillhub.observability;
import io.micrometer.context.ThreadLocalAccessor;
/**
* Captures and restores the authoritative Request ID scope for asynchronous execution.
*/
public final class RequestIdThreadLocalAccessor implements ThreadLocalAccessor<String> {
public static final String KEY = "skillhub.request-id";
private final RequestIdAccessor requestIdAccessor;
public RequestIdThreadLocalAccessor(RequestIdAccessor requestIdAccessor) {
this.requestIdAccessor = requestIdAccessor;
}
@Override
public Object key() {
return KEY;
}
@Override
public String getValue() {
return requestIdAccessor.current();
}
@Override
public void setValue(String value) {
requestIdAccessor.replace(value);
}
@Override
public void setValue() {
requestIdAccessor.replace(null);
}
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.observability;
import com.iflytek.skillhub.observability.tracing.SkillHubObservabilityProperties;
import com.iflytek.skillhub.observability.tracing.TracingMode;
import io.micrometer.context.ContextRegistry;
import io.micrometer.context.ContextSnapshotFactory;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.contextpropagation.ObservationAwareSpanThreadLocalAccessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
/**
* Defines the context captured by SkillHub-managed asynchronous executors.
*/
@Configuration(proxyBeanMethods = false)
public class SkillHubContextPropagationConfiguration {
@Bean
ContextRegistry skillHubContextRegistry(
RequestIdAccessor requestIdAccessor,
SkillHubObservabilityProperties observabilityProperties,
ObservationRegistry observationRegistry,
Tracer tracer
) {
ContextRegistry registry = new ContextRegistry()
.loadContextAccessors()
.loadThreadLocalAccessors();
registry.registerThreadLocalAccessor(
new RequestIdThreadLocalAccessor(requestIdAccessor)
);
if (observabilityProperties.getTracingMode() == TracingMode.OTEL_SDK) {
registry.registerThreadLocalAccessor(
new ObservationAwareSpanThreadLocalAccessor(observationRegistry, tracer)
);
}
return registry;
}
@Bean
ContextPropagatingTaskDecorator skillHubContextPropagatingTaskDecorator(
ContextRegistry skillHubContextRegistry
) {
ContextSnapshotFactory snapshotFactory = ContextSnapshotFactory.builder()
.contextRegistry(skillHubContextRegistry)
.clearMissing(true)
.build();
return new ContextPropagatingTaskDecorator(snapshotFactory);
}
}

View file

@ -0,0 +1,81 @@
package com.iflytek.skillhub.observability.logging;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.fasterxml.jackson.core.JsonGenerator;
import net.logstash.logback.composite.AbstractJsonProvider;
import org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.LogbackMDCPatternConverter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* Writes only the approved correlation fields from MDC.
*/
final class CorrelationJsonProvider extends AbstractJsonProvider<ILoggingEvent> {
private static final String REQUEST_ID_KEY = "requestId";
private static final String TRACE_ID_KEY = "traceId";
private static final String SPAN_ID_KEY = "spanId";
private static final String EXTERNAL_TRACE_ID_KEY = "tid";
private final LogbackMDCPatternConverter externalTraceIdConverter;
CorrelationJsonProvider(boolean externalTraceIdEnabled) {
if (externalTraceIdEnabled) {
externalTraceIdConverter = new LogbackMDCPatternConverter();
externalTraceIdConverter.setOptionList(List.of(EXTERNAL_TRACE_ID_KEY));
externalTraceIdConverter.start();
} else {
externalTraceIdConverter = null;
}
}
@Override
public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException {
Map<String, String> mdc = event.getMDCPropertyMap();
mdc = mdc == null ? Map.of() : mdc;
writeIfPresent(generator, "request.id", mdc.get(REQUEST_ID_KEY));
writeIfPresent(
generator,
"trace.id",
firstPresent(mdc.get(TRACE_ID_KEY), externalTraceId(event, mdc))
);
writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY));
}
private String externalTraceId(ILoggingEvent event, Map<String, String> mdc) {
String traceId = mdc.get(EXTERNAL_TRACE_ID_KEY);
if (!isPresent(traceId) && externalTraceIdConverter != null) {
traceId = externalTraceIdConverter.convert(event);
}
return normalizeExternalTraceId(traceId);
}
private String normalizeExternalTraceId(String traceId) {
if (!isPresent(traceId)) {
return null;
}
String normalized = traceId.trim();
if (normalized.regionMatches(true, 0, "TID:", 0, 4)) {
normalized = normalized.substring(4).trim();
}
return "N/A".equalsIgnoreCase(normalized) ? null : normalized;
}
private String firstPresent(String preferred, String fallback) {
return isPresent(preferred) ? preferred : fallback;
}
private void writeIfPresent(JsonGenerator generator, String fieldName, String value)
throws IOException {
if (isPresent(value)) {
generator.writeStringField(fieldName, value);
}
}
private boolean isPresent(String value) {
return value != null && !value.isBlank();
}
}

View file

@ -0,0 +1,113 @@
package com.iflytek.skillhub.observability.logging;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import net.logstash.logback.composite.GlobalCustomFieldsJsonProvider;
import net.logstash.logback.composite.loggingevent.LogLevelJsonProvider;
import net.logstash.logback.composite.loggingevent.LoggerNameJsonProvider;
import net.logstash.logback.composite.loggingevent.LoggingEventFormattedTimestampJsonProvider;
import net.logstash.logback.composite.loggingevent.LoggingEventJsonProviders;
import net.logstash.logback.composite.loggingevent.LoggingEventThreadNameJsonProvider;
import net.logstash.logback.composite.loggingevent.MessageJsonProvider;
import net.logstash.logback.composite.loggingevent.StackTraceJsonProvider;
import net.logstash.logback.composite.loggingevent.ThrowableClassNameJsonProvider;
import net.logstash.logback.composite.loggingevent.ThrowableMessageJsonProvider;
import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder;
/**
* ECS-style JSON encoder with an explicit field allowlist.
*/
public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
private static final String ECS_VERSION = "1.2.0";
private String serviceName = "skillhub";
private String serviceVersion = "unknown";
private String serviceEnvironment = "local";
private boolean externalTraceIdEnabled;
@Override
public void start() {
if (isStarted()) {
return;
}
setLineSeparator("UNIX");
setProviders(createProviders());
super.start();
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public void setServiceVersion(String serviceVersion) {
this.serviceVersion = serviceVersion;
}
public void setServiceEnvironment(String serviceEnvironment) {
this.serviceEnvironment = serviceEnvironment;
}
public void setTracingMode(String tracingMode) {
this.externalTraceIdEnabled = "external-agent".equalsIgnoreCase(tracingMode);
}
private LoggingEventJsonProviders createProviders() {
LoggingEventJsonProviders providers = new LoggingEventJsonProviders();
LoggingEventFormattedTimestampJsonProvider timestamp =
new LoggingEventFormattedTimestampJsonProvider();
timestamp.setFieldName("@timestamp");
timestamp.setTimeZone("UTC");
providers.addTimestamp(timestamp);
LogLevelJsonProvider level = new LogLevelJsonProvider();
level.setFieldName("log.level");
providers.addLogLevel(level);
MessageJsonProvider message = new MessageJsonProvider();
message.setFieldName("message");
providers.addMessage(message);
LoggerNameJsonProvider logger = new LoggerNameJsonProvider();
logger.setFieldName("log.logger");
providers.addLoggerName(logger);
LoggingEventThreadNameJsonProvider thread = new LoggingEventThreadNameJsonProvider();
thread.setFieldName("process.thread.name");
providers.addThreadName(thread);
providers.addGlobalCustomFields(serviceFields());
providers.addProvider(new CorrelationJsonProvider(externalTraceIdEnabled));
ThrowableClassNameJsonProvider errorType = new ThrowableClassNameJsonProvider();
errorType.setFieldName("error.type");
errorType.setUseSimpleClassName(false);
providers.addThrowableClassName(errorType);
ThrowableMessageJsonProvider errorMessage = new ThrowableMessageJsonProvider();
errorMessage.setFieldName("error.message");
providers.addThrowableMessage(errorMessage);
StackTraceJsonProvider stackTrace = new StackTraceJsonProvider();
stackTrace.setFieldName("error.stack_trace");
providers.addStackTrace(stackTrace);
return providers;
}
private GlobalCustomFieldsJsonProvider<ILoggingEvent> serviceFields() {
ObjectNode fields = JsonNodeFactory.instance.objectNode();
fields.put("ecs.version", ECS_VERSION);
fields.put("service.name", serviceName);
fields.put("service.version", serviceVersion);
fields.put("service.environment", serviceEnvironment);
fields.put("event.dataset", serviceName);
GlobalCustomFieldsJsonProvider<ILoggingEvent> provider =
new GlobalCustomFieldsJsonProvider<>();
provider.setCustomFieldsNode(fields);
return provider;
}
}

View file

@ -0,0 +1,4 @@
/**
* Structured logging adapters for SkillHub correlation fields.
*/
package com.iflytek.skillhub.observability.logging;

View file

@ -0,0 +1,4 @@
/**
* Application-level observability context and integration boundaries.
*/
package com.iflytek.skillhub.observability;

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.observability.tracing;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Startup-time observability choices owned by SkillHub.
*/
@ConfigurationProperties(prefix = "skillhub.observability")
public class SkillHubObservabilityProperties {
private TracingMode tracingMode = TracingMode.NONE;
public TracingMode getTracingMode() {
return tracingMode;
}
public void setTracingMode(TracingMode tracingMode) {
this.tracingMode = tracingMode;
}
}

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.observability.tracing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
/**
* Validates tracing mode combinations that SkillHub can determine at startup.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SkillHubObservabilityProperties.class)
public class SkillHubTracingConfiguration {
private static final Logger log = LoggerFactory.getLogger(SkillHubTracingConfiguration.class);
@Bean
TracingModeGuard tracingModeGuard(
SkillHubObservabilityProperties properties,
Environment environment
) {
TracingMode mode = properties.getTracingMode();
String otlpEndpoint = environment.getProperty("management.otlp.tracing.endpoint");
if (mode != TracingMode.OTEL_SDK && StringUtils.hasText(otlpEndpoint)) {
throw new IllegalStateException(
"management.otlp.tracing.endpoint requires "
+ "skillhub.observability.tracing-mode=otel-sdk"
);
}
if (mode == TracingMode.OTEL_SDK
&& Boolean.FALSE.equals(environment.getProperty(
"management.tracing.enabled",
Boolean.class
))) {
throw new IllegalStateException(
"management.tracing.enabled=false conflicts with "
+ "skillhub.observability.tracing-mode=otel-sdk"
);
}
if (mode == TracingMode.EXTERNAL_AGENT) {
log.warn(
"External tracing agent mode selected. SkillHub cannot verify the agent "
+ "identity; deployment must provide exactly one tracing agent"
);
}
return new TracingModeGuard(mode);
}
record TracingModeGuard(TracingMode mode) {
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.observability.tracing;
/**
* Selects the single tracing implementation that may be active in the application process.
*/
public enum TracingMode {
NONE,
OTEL_SDK,
EXTERNAL_AGENT
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.observability.tracing;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import java.util.Set;
/**
* Keeps the OpenTelemetry SDK outside the application context unless the deployment explicitly
* selects {@code otel-sdk}. The normal Spring Boot NOOP tracer remains available in the other
* modes.
*/
public final class TracingModeAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
static final String TRACING_MODE_PROPERTY = "skillhub.observability.tracing-mode";
private static final Set<String> OTEL_AUTO_CONFIGURATIONS = Set.of(
"org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration",
"org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration",
"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration"
);
private Environment environment;
@Override
public boolean[] match(
String[] autoConfigurationClasses,
AutoConfigurationMetadata autoConfigurationMetadata
) {
boolean otelSdkEnabled = environment != null
&& "otel-sdk".equalsIgnoreCase(
environment.getProperty(TRACING_MODE_PROPERTY, "none")
);
boolean[] matches = new boolean[autoConfigurationClasses.length];
for (int index = 0; index < autoConfigurationClasses.length; index++) {
String autoConfigurationClass = autoConfigurationClasses[index];
matches[index] = autoConfigurationClass != null
&& (otelSdkEnabled
|| !OTEL_AUTO_CONFIGURATIONS.contains(autoConfigurationClass));
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}

View file

@ -0,0 +1,4 @@
/**
* Startup tracing mode selection and auto-configuration boundaries.
*/
package com.iflytek.skillhub.observability.tracing;

View file

@ -5,12 +5,12 @@ import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
@ -26,13 +26,16 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final RequestIdAccessor requestIdAccessor;
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
SensitiveLogSanitizer sensitiveLogSanitizer,
RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.requestIdAccessor = requestIdAccessor;
}
@Override
@ -45,7 +48,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
: null;
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName(),

View file

@ -4,12 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
@ -25,13 +25,16 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final RequestIdAccessor requestIdAccessor;
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
SensitiveLogSanitizer sensitiveLogSanitizer,
RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.requestIdAccessor = requestIdAccessor;
}
@Override
@ -40,7 +43,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
AuthenticationException authException) throws IOException {
logger.info(
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()

View file

@ -11,9 +11,9 @@ import com.iflytek.skillhub.dto.AdminLabelUpdateRequest;
import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@ -27,17 +27,20 @@ public class LabelAdminAppService {
private final AuditLogService auditLogService;
private final RbacService rbacService;
private final LabelSearchSyncService labelSearchSyncService;
private final RequestIdAccessor requestIdAccessor;
public LabelAdminAppService(LabelDefinitionService labelDefinitionService,
SkillLabelService skillLabelService,
AuditLogService auditLogService,
RbacService rbacService,
LabelSearchSyncService labelSearchSyncService) {
LabelSearchSyncService labelSearchSyncService,
RequestIdAccessor requestIdAccessor) {
this.labelDefinitionService = labelDefinitionService;
this.skillLabelService = skillLabelService;
this.auditLogService = auditLogService;
this.rbacService = rbacService;
this.labelSearchSyncService = labelSearchSyncService;
this.requestIdAccessor = requestIdAccessor;
}
public List<LabelDefinitionResponse> listAll() {
@ -153,7 +156,7 @@ public class LabelAdminAppService {
action,
"LABEL",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -12,11 +12,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@ -32,17 +32,20 @@ public class PromotionPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public PromotionPortalAppService(PromotionService promotionService,
PromotionRequestRepository promotionRequestRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.promotionService = promotionService;
this.promotionRequestRepository = promotionRequestRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public PromotionResponseDto submitPromotion(Long sourceSkillId,
@ -235,7 +238,7 @@ public class PromotionPortalAppService {
action,
"PROMOTION_REQUEST",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -13,11 +13,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@ -34,19 +34,22 @@ public class ReviewPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public ReviewPortalAppService(ReviewService reviewService,
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.reviewService = reviewService;
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public ReviewTaskResponse submitReview(Long skillVersionId,
@ -256,7 +259,7 @@ public class ReviewPortalAppService {
action,
"REVIEW_TASK",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -18,12 +18,12 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@ -42,6 +42,7 @@ public class SkillLabelAppService {
private final AuditLogService auditLogService;
private final LabelSearchSyncService labelSearchSyncService;
private final SkillSlugResolutionService skillSlugResolutionService;
private final RequestIdAccessor requestIdAccessor;
public SkillLabelAppService(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
@ -52,7 +53,8 @@ public class SkillLabelAppService {
RbacService rbacService,
AuditLogService auditLogService,
LabelSearchSyncService labelSearchSyncService,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
RequestIdAccessor requestIdAccessor) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.visibilityChecker = visibilityChecker;
@ -63,6 +65,7 @@ public class SkillLabelAppService {
this.auditLogService = auditLogService;
this.labelSearchSyncService = labelSearchSyncService;
this.skillSlugResolutionService = skillSlugResolutionService;
this.requestIdAccessor = requestIdAccessor;
}
public List<SkillLabelDto> listSkillLabels(String namespaceSlug,
@ -188,7 +191,7 @@ public class SkillLabelAppService {
action,
"SKILL",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
com.iflytek.skillhub.observability.tracing.TracingModeAutoConfigurationImportFilter

View file

@ -94,6 +94,12 @@ spring:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
skillhub:
observability:
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
log-format: ${SKILLHUB_LOG_FORMAT:text}
log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024}
service-version: ${SKILLHUB_SERVICE_VERSION:unknown}
service-environment: ${SKILLHUB_SERVICE_ENVIRONMENT:local}
builtin-skills:
enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true}
redis:
@ -215,6 +221,17 @@ skillhub:
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
management:
tracing:
sampling:
probability: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:0.1}
baggage:
enabled: false
propagation:
type: W3C
otlp:
tracing:
timeout: ${SKILLHUB_OTLP_TIMEOUT:5s}
compression: ${SKILLHUB_OTLP_COMPRESSION:gzip}
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty name="activeAppender"
source="skillhub.observability.log-format"
defaultValue="text"/>
<springProperty name="tracingMode"
source="skillhub.observability.tracing-mode"
defaultValue="none"/>
<springProperty name="asyncQueueSize"
source="skillhub.observability.log-async-queue-size"
defaultValue="1024"/>
<springProperty name="serviceName"
source="spring.application.name"
defaultValue="skillhub"/>
<springProperty name="serviceVersion"
source="skillhub.observability.service-version"
defaultValue="unknown"/>
<springProperty name="serviceEnvironment"
source="skillhub.observability.service-environment"
defaultValue="local"/>
<appender name="text" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<charset>${CONSOLE_LOG_CHARSET}</charset>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="json-console" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="com.iflytek.skillhub.observability.logging.SkillHubEcsEncoder">
<serviceName>${serviceName}</serviceName>
<serviceVersion>${serviceVersion}</serviceVersion>
<serviceEnvironment>${serviceEnvironment}</serviceEnvironment>
<tracingMode>${tracingMode}</tracingMode>
</encoder>
</appender>
<appender name="json" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>${asyncQueueSize}</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<includeCallerData>false</includeCallerData>
<appender-ref ref="json-console"/>
</appender>
<root level="INFO">
<appender-ref ref="${activeAppender}"/>
</root>
</configuration>

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
@ -40,7 +41,8 @@ class ClawHubCompatAppServiceTest {
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
skillStarService,
new RequestIdAccessor()
);
@Test

View file

@ -2,10 +2,14 @@ package com.iflytek.skillhub.config;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.observability.RequestIdThreadLocalAccessor;
import io.micrometer.context.ContextRegistry;
import io.micrometer.context.ContextSnapshotFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@ -19,23 +23,35 @@ class AsyncConfigTest {
}
@Test
void skillhubEventExecutor_propagatesAndClearsMdc() throws Exception {
void skillhubEventExecutor_propagatesAndClearsRequestIdContext() throws Exception {
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ContextRegistry contextRegistry = new ContextRegistry()
.registerThreadLocalAccessor(
new RequestIdThreadLocalAccessor(requestIdAccessor)
);
ContextSnapshotFactory snapshotFactory = ContextSnapshotFactory.builder()
.contextRegistry(contextRegistry)
.clearMissing(true)
.build();
ThreadPoolTaskExecutor executor =
(ThreadPoolTaskExecutor) new AsyncConfig().skillhubEventExecutor();
(ThreadPoolTaskExecutor) new AsyncConfig().skillhubEventExecutor(
new ContextPropagatingTaskDecorator(snapshotFactory)
);
try {
MDC.put("requestId", "req-597");
CompletableFuture<String> propagatedRequestId = new CompletableFuture<>();
executor.execute(() -> propagatedRequestId.complete(MDC.get("requestId")));
MDC.clear();
try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("req-597")) {
executor.execute(
() -> propagatedRequestId.complete(requestIdAccessor.current())
);
}
assertThat(propagatedRequestId.get(5, TimeUnit.SECONDS)).isEqualTo("req-597");
CompletableFuture<String> nextRequestId = new CompletableFuture<>();
executor.execute(() -> nextRequestId.complete(MDC.get("requestId")));
executor.execute(() -> nextRequestId.complete(requestIdAccessor.current()));
assertThat(nextRequestId.get(5, TimeUnit.SECONDS)).isNull();
} finally {
MDC.clear();
executor.shutdown();
}
}

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserProfileService;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -54,9 +55,11 @@ class UserProfileControllerUnitTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("response.success.read", Locale.getDefault(), "response.success.read");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
controller = new UserProfileController(
responseFactory,
@ -64,7 +67,8 @@ class UserProfileControllerUnitTest {
userAccountRepository,
changeRequestRepository,
platformSessionService,
fieldPolicyConfig
fieldPolicyConfig,
requestIdAccessor
);
given(fieldPolicyConfig.fieldPolicies()).willReturn(Map.of(
"displayName", new ProfileFieldPolicyConfig.FieldPolicy(true, false),

View file

@ -12,6 +12,7 @@ import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@ -42,7 +43,8 @@ class NotificationControllerTest {
messageSource.addMessage("response.success.read", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
new RequestIdAccessor()
);
controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory);
}

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.domain.NotificationChannel;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService.PreferenceView;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@ -41,7 +42,8 @@ class NotificationPreferenceControllerTest {
messageSource.addMessage("response.success.updated", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC),
new RequestIdAccessor()
);
controller = new NotificationPreferenceController(preferenceService, responseFactory);
}

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
@ -46,11 +47,18 @@ class GlobalExceptionHandlerTest {
"error.auth.local.invalidCredentials",
java.util.Locale.getDefault(),
"Invalid username or password");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
handler = new GlobalExceptionHandler(
responseFactory,
sensitiveLogSanitizer,
metrics,
requestIdAccessor
);
handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics);
}
@Test

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpSession;
import java.time.Clock;
@ -47,7 +48,8 @@ class AuthContextFilterTest {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled");
Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock);
ApiResponseFactory apiResponseFactory =
new ApiResponseFactory(messageSource, clock, new RequestIdAccessor());
filter = new AuthContextFilter(
namespaceMemberRepository,
userAccountRepository,

View file

@ -5,6 +5,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
@ -38,6 +39,8 @@ class IdempotencyInterceptorTest {
@Mock
private ValueOperations<String, String> valueOperations;
@Mock
private RequestIdAccessor requestIdAccessor;
@Mock
private HttpServletRequest request;
@ -53,13 +56,20 @@ class IdempotencyInterceptorTest {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock);
interceptor = new IdempotencyInterceptor(
redisTemplate,
idempotencyRecordRepository,
objectMapper,
clock,
requestIdAccessor
);
}
@Test
void testNewRequestPassesThrough() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-123");
when(requestIdAccessor.current()).thenReturn("req-123");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-123")).thenReturn(null);
when(idempotencyRecordRepository.findByRequestId("req-123")).thenReturn(Optional.empty());
@ -70,10 +80,28 @@ class IdempotencyInterceptorTest {
verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class));
}
@Test
void testProvidedInvalidHeaderUsesEffectiveRequestContext() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("invalid request id");
when(requestIdAccessor.current()).thenReturn("generated-valid-id");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:generated-valid-id")).thenReturn(null);
when(idempotencyRecordRepository.findByRequestId("generated-valid-id"))
.thenReturn(Optional.empty());
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
verify(idempotencyRecordRepository).findByRequestId("generated-valid-id");
verify(idempotencyRecordRepository, never()).findByRequestId("invalid request id");
}
@Test
void testDuplicateRequestReturnsCachedResponse() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-456");
when(requestIdAccessor.current()).thenReturn("req-456");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-456")).thenReturn("COMPLETED");
@ -114,6 +142,7 @@ class IdempotencyInterceptorTest {
void testAfterCompletionUpdatesRecord() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-789");
when(requestIdAccessor.current()).thenReturn("req-789");
when(response.getStatus()).thenReturn(200);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);

View file

@ -1,14 +1,19 @@
package com.iflytek.skillhub.filter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@ -23,7 +28,8 @@ class RequestIdFilterTest {
void shouldGenerateRequestIdWhenNotProvided() throws Exception {
mockMvc.perform(get("/api/v1/health"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"));
.andExpect(header().exists("X-Request-Id"))
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
@ -32,6 +38,50 @@ class RequestIdFilterTest {
mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", requestId))
.andExpect(status().isOk())
.andExpect(header().string("X-Request-Id", requestId));
.andExpect(header().string("X-Request-Id", requestId))
.andExpect(jsonPath("$.requestId").value(requestId));
}
@Test
void shouldPreserveRequestIdAtMaximumLength() throws Exception {
String requestId = "a".repeat(64);
mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", requestId))
.andExpect(status().isOk())
.andExpect(header().string("X-Request-Id", requestId))
.andExpect(jsonPath("$.requestId").value(requestId));
}
@ParameterizedTest
@ValueSource(strings = {
"",
"-starts-with-symbol",
"contains space",
"contains/slash",
"包含中文"
})
void shouldReplaceInvalidRequestId(String requestId) throws Exception {
assertInvalidRequestIdIsReplaced(requestId);
}
@Test
void shouldReplaceRequestIdLongerThanMaximumLength() throws Exception {
assertInvalidRequestIdIsReplaced("a".repeat(65));
}
private void assertInvalidRequestIdIsReplaced(String invalidRequestId) throws Exception {
MvcResult result = mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", invalidRequestId))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"))
.andExpect(jsonPath("$.requestId").isNotEmpty())
.andReturn();
String effectiveRequestId = result.getResponse().getHeader("X-Request-Id");
assertThat(effectiveRequestId)
.isNotEqualTo(invalidRequestId)
.matches("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
assertThat(result.getResponse().getContentAsString()).contains(effectiveRequestId);
}
}

View file

@ -0,0 +1,320 @@
package com.iflytek.skillhub.observability;
import com.iflytek.skillhub.config.AsyncConfig;
import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContextPropagationConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none"
);
@Test
void requestIdShouldPropagateRestoreNestedScopeAndNotLeakOnThreadReuse() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
ContextValues propagated;
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("request-one")) {
propagated = execute(worker, taskDecorator, () -> {
assertThat(requestIdAccessor.current())
.isEqualTo("request-one");
try (RequestIdAccessor.Scope nested =
requestIdAccessor.open("nested")) {
assertThat(requestIdAccessor.current())
.isEqualTo("nested");
}
return currentValues(requestIdAccessor, null);
});
}
assertThat(propagated.requestId()).isEqualTo("request-one");
assertThat(propagated.mdcRequestId()).isEqualTo("request-one");
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("request-failure")) {
assertThatThrownBy(() -> execute(
worker,
taskDecorator,
() -> {
throw new IllegalStateException("expected failure");
}
)).hasCauseInstanceOf(IllegalStateException.class);
}
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, null)
);
assertThat(clean.requestId()).isNull();
assertThat(clean.mdcRequestId()).isNull();
} finally {
worker.shutdownNow();
MDC.clear();
}
});
}
@Test
void configuredEventExecutorShouldPropagateRequestId() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
Executor executor = context.getBean("skillhubEventExecutor", Executor.class);
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("configured-executor")) {
FutureTask<ContextValues> task = new FutureTask<>(
() -> currentValues(requestIdAccessor, null)
);
executor.execute(task);
assertThat(task.get(5, TimeUnit.SECONDS).requestId())
.isEqualTo("configured-executor");
} finally {
MDC.clear();
}
});
}
@Test
void callerRunsPolicyShouldRestoreCallerScopeAndLeaveWorkerClean() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
ThreadPoolTaskExecutor executor = callerRunsExecutor(taskDecorator);
CountDownLatch workerStarted = new CountDownLatch(1);
CountDownLatch releaseWorker = new CountDownLatch(1);
FutureTask<Void> blockingTask = new FutureTask<>(() -> {
workerStarted.countDown();
releaseWorker.await(5, TimeUnit.SECONDS);
return null;
});
try {
executor.execute(blockingTask);
assertThat(workerStarted.await(5, TimeUnit.SECONDS)).isTrue();
AtomicReference<ContextValues> callerRunValues =
new AtomicReference<>();
String callerThread = Thread.currentThread().getName();
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("caller-request")) {
executor.execute(() -> {
assertThat(Thread.currentThread().getName())
.isEqualTo(callerThread);
callerRunValues.set(currentValues(
requestIdAccessor,
null
));
});
assertThat(requestIdAccessor.current())
.isEqualTo("caller-request");
assertThat(MDC.get(RequestIdAccessor.MDC_KEY))
.isEqualTo("caller-request");
}
assertThat(callerRunValues.get().requestId())
.isEqualTo("caller-request");
releaseWorker.countDown();
blockingTask.get(5, TimeUnit.SECONDS);
FutureTask<ContextValues> cleanTask = new FutureTask<>(
() -> currentValues(requestIdAccessor, null)
);
executor.execute(cleanTask);
ContextValues clean = cleanTask.get(5, TimeUnit.SECONDS);
assertThat(clean.requestId()).isNull();
assertThat(clean.mdcRequestId()).isNull();
} finally {
releaseWorker.countDown();
executor.shutdown();
MDC.clear();
}
});
}
@Test
void otelSpanShouldPropagateAndBeClearedAfterTask() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
Tracer tracer = context.getBean(Tracer.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
Span span = tracer.nextSpan().name("parent").start();
try {
ContextValues propagated;
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
propagated = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
}
assertThat(propagated.traceId())
.isEqualTo(span.context().traceId());
assertThat(propagated.mdcTraceId())
.isEqualTo(span.context().traceId());
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
assertThat(clean.traceId()).isNull();
assertThat(clean.mdcTraceId()).isNull();
} finally {
span.end();
worker.shutdownNow();
MDC.clear();
}
});
}
@Test
void otelObservationShouldPropagateItsTraceAndRestoreWorker() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
Tracer tracer = context.getBean(Tracer.class);
ObservationRegistry observationRegistry =
context.getBean(ObservationRegistry.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
Observation observation = Observation
.createNotStarted("parent-observation", observationRegistry)
.start();
try {
ContextValues propagated;
String parentTraceId;
try (Observation.Scope ignored = observation.openScope()) {
assertThat(tracer.currentSpan()).isNotNull();
parentTraceId = tracer.currentSpan().context().traceId();
propagated = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
}
assertThat(propagated.traceId()).isEqualTo(parentTraceId);
assertThat(propagated.mdcTraceId()).isEqualTo(parentTraceId);
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
assertThat(clean.traceId()).isNull();
assertThat(clean.mdcTraceId()).isNull();
} finally {
observation.stop();
worker.shutdownNow();
MDC.clear();
}
});
}
private ContextValues currentValues(
RequestIdAccessor requestIdAccessor,
Tracer tracer
) {
Span currentSpan = tracer == null ? null : tracer.currentSpan();
return new ContextValues(
requestIdAccessor.current(),
MDC.get(RequestIdAccessor.MDC_KEY),
currentSpan == null ? null : currentSpan.context().traceId(),
MDC.get("traceId")
);
}
private <T> T execute(
Executor executor,
TaskDecorator taskDecorator,
Callable<T> action
) throws Exception {
FutureTask<T> task = new FutureTask<>(action);
executor.execute(taskDecorator.decorate(task));
return task.get(5, TimeUnit.SECONDS);
}
private ThreadPoolTaskExecutor callerRunsExecutor(TaskDecorator taskDecorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(0);
executor.setTaskDecorator(taskDecorator);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
private record ContextValues(
String requestId,
String mdcRequestId,
String traceId,
String mdcTraceId
) {
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
SkillHubTracingConfiguration.class,
SkillHubContextPropagationConfiguration.class,
RequestIdAccessor.class,
AsyncConfig.class
})
static class TestApplication {
}
}

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.observability;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
class RequestIdAccessorTest {
private final RequestIdAccessor accessor = new RequestIdAccessor();
@AfterEach
void tearDown() {
MDC.clear();
}
@Test
void shouldMirrorRequestIdToMdcAndClearItWhenScopeCloses() {
assertThat(accessor.current()).isNull();
assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
try (RequestIdAccessor.Scope ignored = accessor.open("req-123")) {
assertThat(accessor.current()).isEqualTo("req-123");
assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("req-123");
}
assertThat(accessor.current()).isNull();
assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
}
@Test
void shouldRestoreOuterScope() {
try (RequestIdAccessor.Scope ignored = accessor.open("outer")) {
try (RequestIdAccessor.Scope nested = accessor.open("inner")) {
assertThat(accessor.current()).isEqualTo("inner");
}
assertThat(accessor.current()).isEqualTo("outer");
assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("outer");
}
}
@Test
void shouldUseThreadLocalAsAuthorityWhenMdcIsChangedExternally() {
try (RequestIdAccessor.Scope ignored = accessor.open("authoritative")) {
MDC.put(RequestIdAccessor.MDC_KEY, "logging-only");
assertThat(accessor.current()).isEqualTo("authoritative");
}
assertThat(accessor.current()).isNull();
assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
}
@Test
void shouldRejectBlankRequestId() {
assertThatIllegalArgumentException()
.isThrownBy(() -> accessor.open(" "));
}
}

View file

@ -0,0 +1,142 @@
package com.iflytek.skillhub.observability.logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.LoggingEvent;
import ch.qos.logback.classic.spi.ThrowableProxy;
import ch.qos.logback.classic.util.LogbackMDCAdapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SkillHubEcsEncoderTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final LoggerContext loggerContext = new LoggerContext();
private final SkillHubEcsEncoder encoder = new SkillHubEcsEncoder();
@BeforeEach
void setUp() {
loggerContext.setMDCAdapter(new LogbackMDCAdapter());
encoder.setContext(loggerContext);
encoder.setServiceName("skillhub");
encoder.setServiceVersion("test-sha");
encoder.setServiceEnvironment("test");
encoder.start();
}
@AfterEach
void tearDown() {
encoder.stop();
loggerContext.stop();
}
@Test
void shouldWriteEcsFieldsAndOnlyApprovedMdcValues() throws Exception {
LoggingEvent event = event("hello");
event.setMDCPropertyMap(Map.of(
"requestId", "req-123",
"traceId", "trace-123",
"spanId", "span-123",
"authorization", "must-not-leak",
"userEmail", "must-not-leak"
));
JsonNode json = encode(event);
assertThat(json.path("log.level").asText()).isEqualTo("INFO");
assertThat(json.path("log.logger").asText()).isEqualTo("test.logger");
assertThat(json.path("message").asText()).isEqualTo("hello");
assertThat(json.path("service.name").asText()).isEqualTo("skillhub");
assertThat(json.path("service.version").asText()).isEqualTo("test-sha");
assertThat(json.path("service.environment").asText()).isEqualTo("test");
assertThat(json.path("request.id").asText()).isEqualTo("req-123");
assertThat(json.path("trace.id").asText()).isEqualTo("trace-123");
assertThat(json.path("span.id").asText()).isEqualTo("span-123");
assertThat(json.has("authorization")).isFalse();
assertThat(json.has("userEmail")).isFalse();
}
@Test
void shouldPreferMicrometerTraceIdOverExternalAgentFallback() throws Exception {
LoggingEvent event = event("trace precedence");
event.setMDCPropertyMap(Map.of(
"traceId", "micrometer-trace",
"tid", "external-agent-trace"
));
JsonNode json = encode(event);
assertThat(json.path("trace.id").asText()).isEqualTo("micrometer-trace");
assertThat(json.fieldNames()).toIterable()
.filteredOn("trace.id"::equals)
.hasSize(1);
}
@Test
void shouldNormalizeExternalAgentTraceId() throws Exception {
encoder.stop();
encoder.setTracingMode("external-agent");
encoder.start();
LoggingEvent event = event("external trace");
event.setMDCPropertyMap(Map.of("tid", "TID: external-agent-trace"));
JsonNode json = encode(event);
assertThat(json.path("trace.id").asText()).isEqualTo("external-agent-trace");
}
@Test
void shouldNotWriteToolkitSentinelAsTraceId() throws Exception {
encoder.stop();
encoder.setTracingMode("external-agent");
encoder.start();
LoggingEvent event = event("no external agent");
event.setMDCPropertyMap(Map.of("tid", "TID: N/A"));
JsonNode json = encode(event);
assertThat(json.has("trace.id")).isFalse();
}
@Test
void shouldWriteStructuredExceptionFields() throws Exception {
LoggingEvent event = event("failed");
event.setThrowableProxy(new ThrowableProxy(new IllegalStateException("boom")));
JsonNode json = encode(event);
assertThat(json.path("error.type").asText())
.isEqualTo(IllegalStateException.class.getName());
assertThat(json.path("error.message").asText()).isEqualTo("boom");
assertThat(json.path("error.stack_trace").asText())
.contains("IllegalStateException: boom");
}
private LoggingEvent event(String message) {
Logger logger = loggerContext.getLogger("test.logger");
LoggingEvent event = new LoggingEvent(
getClass().getName(),
logger,
Level.INFO,
message,
null,
null
);
event.setThreadName("test-thread");
event.setTimeStamp(1_785_465_600_000L);
return event;
}
private JsonNode encode(LoggingEvent event) throws Exception {
return objectMapper.readTree(new String(encoder.encode(event), StandardCharsets.UTF_8));
}
}

View file

@ -0,0 +1,178 @@
package com.iflytek.skillhub.observability.tracing;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.oauth.GitLabClaimsExtractor;
import com.iflytek.skillhub.config.SkillScannerConfig;
import com.iflytek.skillhub.config.SkillScannerProperties;
import com.iflytek.skillhub.infra.http.HttpClient;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class HttpTracePropagationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none",
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0",
"skillhub.security.scanner.enabled=true"
);
@Test
void shouldPropagateW3cContextToScannerButNotExternalGitLab() throws Exception {
try (HeaderCaptureServer scannerServer =
new HeaderCaptureServer("text/plain", "scanner-ok");
HeaderCaptureServer gitLabServer =
new HeaderCaptureServer(
"application/json",
"""
[
{
"email": "alice@gitlab.example",
"confirmed_at": "2026-04-16T08:00:00Z"
}
]
"""
)) {
contextRunner.run(context -> {
Tracer tracer = context.getBean(Tracer.class);
HttpClient scannerClient =
context.getBean("scannerHttpClient", HttpClient.class);
GitLabClaimsExtractor gitLabClaimsExtractor =
context.getBean(GitLabClaimsExtractor.class);
Span span = tracer.nextSpan().name("outbound-boundary").start();
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
assertThat(scannerClient.get(
scannerServer.url("/health"),
String.class
)).isEqualTo("scanner-ok");
ProviderAuthenticationResult claims = gitLabClaimsExtractor.extract(
gitLabRequest(gitLabServer.url("/api/v4/user")),
new DefaultOAuth2User(
List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice+pending@gitlab.example"
),
"username"
)
);
assertThat(claims.attributes().get("email").getFirst().value())
.isEqualTo("alice@gitlab.example");
} finally {
span.end();
}
String scannerTraceparent = scannerServer.traceparent();
assertThat(scannerTraceparent)
.matches("^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$");
assertThat(scannerTraceparent.substring(3, 35))
.isEqualTo(span.context().traceId());
assertThat(gitLabServer.traceparent()).isNull();
});
}
}
private OAuth2UserRequest gitLabRequest(String userInfoUri) {
ClientRegistration registration = ClientRegistration
.withRegistrationId("gitlab")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("read_user", "email")
.authorizationUri("https://gitlab.example/oauth/authorize")
.tokenUri("https://gitlab.example/oauth/token")
.userInfoUri(userInfoUri)
.userNameAttributeName("username")
.clientName("GitLab")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"test-token",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
private static final class HeaderCaptureServer implements AutoCloseable {
private final HttpServer server;
private final AtomicReference<String> traceparent = new AtomicReference<>();
private HeaderCaptureServer(String contentType, String body) throws IOException {
byte[] response = body.getBytes(StandardCharsets.UTF_8);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> respond(
exchange,
contentType,
response
));
server.start();
}
private void respond(
HttpExchange exchange,
String contentType,
byte[] response
) throws IOException {
traceparent.set(exchange.getRequestHeaders().getFirst("traceparent"));
exchange.getResponseHeaders().set("Content-Type", contentType);
exchange.sendResponseHeaders(200, response.length);
try (var responseBody = exchange.getResponseBody()) {
responseBody.write(response);
}
}
private String url(String path) {
return "http://127.0.0.1:" + server.getAddress().getPort() + path;
}
private String traceparent() {
return traceparent.get();
}
@Override
public void close() {
server.stop(0);
}
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
SkillHubTracingConfiguration.class,
SkillScannerConfig.class,
SkillScannerProperties.class,
GitLabClaimsExtractor.class
})
static class TestApplication {
}
}

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.observability.tracing;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.otel.bridge.OtelTracer;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class SkillHubTracingConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none"
);
@Test
void noneModeShouldUseNoopTracerAndNoOtelSdk() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isSameAs(Tracer.NOOP);
assertThat(context).doesNotHaveBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void externalAgentModeShouldUseNoopTracerAndNoOtelSdk() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=external-agent")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isSameAs(Tracer.NOOP);
assertThat(context).doesNotHaveBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void otelSdkModeWithoutEndpointShouldCreateInProcessTracerOnly() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0",
"management.tracing.baggage.enabled=false",
"management.tracing.propagation.type=W3C"
)
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isInstanceOf(OtelTracer.class);
assertThat(context).hasSingleBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void otelSdkModeShouldCreateExporterOnlyWhenEndpointIsConfigured() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.otlp.tracing.endpoint=http://127.0.0.1:4318/v1/traces"
)
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class);
});
}
@Test
void nonOtelModeShouldRejectConfiguredOtlpEndpoint() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=none",
"management.otlp.tracing.endpoint=http://127.0.0.1:4318/v1/traces"
)
.run(context -> assertThat(context).hasFailed());
}
@Test
void otelSdkModeShouldRejectDisabledTracing() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.enabled=false"
)
.run(context -> assertThat(context).hasFailed());
}
@Test
void otelSdkScopeShouldPublishTraceCorrelationToMdc() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
Tracer tracer = context.getBean(Tracer.class);
io.micrometer.tracing.Span span = tracer.nextSpan().name("test-span").start();
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
assertThat(MDC.get("traceId")).hasSize(32);
assertThat(MDC.get("spanId")).hasSize(16);
} finally {
span.end();
MDC.clear();
}
});
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import(SkillHubTracingConfiguration.class)
static class TestApplication {
}
}

View file

@ -0,0 +1,65 @@
package com.iflytek.skillhub.observability.tracing;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class TracingModeAutoConfigurationImportFilterTest {
private static final String CORE_OTEL_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration";
private static final String TRACING_OTEL_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration";
private static final String OTLP_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration";
private static final String NOOP_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.NoopTracerAutoConfiguration";
private final TracingModeAutoConfigurationImportFilter filter =
new TracingModeAutoConfigurationImportFilter();
@Test
void shouldExcludeApplicationOtelForDefaultNoneMode() {
filter.setEnvironment(new MockEnvironment());
assertThat(matches()).containsExactly(false, false, false, true, false);
}
@Test
void shouldExcludeApplicationOtelForExternalAgentMode() {
filter.setEnvironment(new MockEnvironment()
.withProperty(
TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
"external-agent"
));
assertThat(matches()).containsExactly(false, false, false, true, false);
}
@Test
void shouldEnableApplicationOtelOnlyForOtelSdkMode() {
filter.setEnvironment(new MockEnvironment()
.withProperty(
TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
"otel-sdk"
));
assertThat(matches()).containsExactly(true, true, true, true, false);
}
private boolean[] matches() {
return filter.match(
new String[]{
CORE_OTEL_AUTO_CONFIGURATION,
TRACING_OTEL_AUTO_CONFIGURATION,
OTLP_AUTO_CONFIGURATION,
NOOP_AUTO_CONFIGURATION,
null
},
mock(AutoConfigurationMetadata.class)
);
}
}

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.auth.token.ApiTokenScopeService;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import java.time.Clock;
import java.time.Instant;
@ -19,7 +20,6 @@ import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
@ -33,28 +33,32 @@ class ApiAccessDeniedHandlerTest {
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ApiAccessDeniedHandler handler;
private RequestIdAccessor.Scope requestIdScope;
@BeforeEach
void setUp() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
handler = new ApiAccessDeniedHandler(
objectMapper,
responseFactory,
new SensitiveLogSanitizer()
new SensitiveLogSanitizer(),
requestIdAccessor
);
MDC.put("requestId", "req-610");
requestIdScope = requestIdAccessor.open("req-610");
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
@AfterEach
void tearDown() {
MDC.clear();
requestIdScope.close();
LocaleContextHolder.resetLocaleContext();
SecurityContextHolder.clearContext();
}

View file

@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@ -30,6 +31,7 @@ class ApiAuthenticationEntryPointTest {
new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
entryPoint = new ApiAuthenticationEntryPoint(
objectMapper,
new ApiResponseFactory(
@ -37,8 +39,10 @@ class ApiAuthenticationEntryPointTest {
Clock.fixed(
Instant.parse(
"2026-07-31T00:00:00Z"),
ZoneOffset.UTC)),
new SensitiveLogSanitizer());
ZoneOffset.UTC),
requestIdAccessor),
new SensitiveLogSanitizer(),
requestIdAccessor);
LocaleContextHolder.setLocale(Locale.ENGLISH);
}

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderItemRequest;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationItemRequest;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@ -40,7 +41,8 @@ class LabelAdminAppServiceTest {
skillLabelService,
auditLogService,
rbacService,
labelSearchSyncService
labelSearchSyncService,
new RequestIdAccessor()
);
@Test

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.lang.reflect.Field;
import java.util.Set;
@ -47,7 +48,8 @@ class PromotionPortalAppServiceTest {
promotionRequestRepository,
governanceQueryRepository,
rbacService,
auditLogService
auditLogService,
new RequestIdAccessor()
);
}

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
@ -70,7 +71,8 @@ class SkillLabelAppServiceTest {
rbacService,
auditLogService,
labelSearchSyncService,
skillSlugResolutionService
skillSlugResolutionService,
new RequestIdAccessor()
);
}

View file

@ -34,6 +34,14 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
private final RestClient restClient;
/**
* Uses an external-service client that is intentionally not customized with application
* tracing. Trace context must not be propagated to a user-configured GitLab host.
*/
public GitLabClaimsExtractor() {
this(RestClient.builder());
}
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)