mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
chore(integration): stage observability validation on big-main
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
85fb05ff8f
60 changed files with 2693 additions and 118 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 Actuator;Prometheus 是可选后端,不是 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 安全扫描服务
|
||||
|
||||
|
|
|
|||
523
docs/2026-07-31-observability-construction-plan.md
Normal file
523
docs/2026-07-31-observability-construction-plan.md
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
# SkillHub 日志关联与链路追踪建设方案
|
||||
|
||||
> 日期:2026-07-31
|
||||
>
|
||||
> 状态:Accepted(2026-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`,但必须同时满足:
|
||||
|
||||
- 长度为 1–64 个字符。
|
||||
- 首字符是字母或数字。
|
||||
- 其余字符只允许字母、数字、`.`、`_`、`:`、`-`。
|
||||
|
||||
建议校验表达式:
|
||||
|
||||
```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。
|
||||
- 其他现有 Client:GitHub、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 远端原型
|
||||
|
||||
#### 原型 A:none
|
||||
|
||||
- 不部署 Collector。
|
||||
- SkillHub 正常启动并完成核心 Smoke Test。
|
||||
- 日志存在 `request.id`,不存在伪造的 Trace 字段。
|
||||
|
||||
#### 原型 B:otel-sdk
|
||||
|
||||
- SkillHub → Collector → SkyWalking 跑通。
|
||||
- JSON 日志进入 Elasticsearch/Kibana。
|
||||
- Kibana 与 SkyWalking 能用同一 `trace.id` 查询。
|
||||
- Collector 停止后 SkillHub API 和异步任务继续工作。
|
||||
|
||||
#### 原型 C:external-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)
|
||||
124
docs/observability-decision-map.md
Normal file
124
docs/observability-decision-map.md
Normal 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 Map;baggage 默认关闭,任何允许项都必须低敏、限长、显式配置。
|
||||
- 无效或不可信的公网 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`;远端实测结果只保存在本地私有
|
||||
中文报告中。
|
||||
120
docs/research/2026-07-31-observability-common-solutions.md
Normal file
120
docs/research/2026-07-31-observability-common-solutions.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Java / Spring 通用日志关联与链路追踪方案调研
|
||||
|
||||
调研时间:2026-07-31
|
||||
适用基线:SkillHub,Spring 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() + "\"}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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\"}"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Structured logging adapters for SkillHub correlation fields.
|
||||
*/
|
||||
package com.iflytek.skillhub.observability.logging;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Application-level observability context and integration boundaries.
|
||||
*/
|
||||
package com.iflytek.skillhub.observability;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Startup tracing mode selection and auto-configuration boundaries.
|
||||
*/
|
||||
package com.iflytek.skillhub.observability.tracing;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
|
||||
com.iflytek.skillhub.observability.tracing.TracingModeAutoConfigurationImportFilter
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
51
server/skillhub-app/src/main/resources/logback-spring.xml
Normal file
51
server/skillhub-app/src/main/resources/logback-spring.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue