mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
Merge pull request #1029 from xiaoyaner0201/feat/otlp-business-events-1009
feat(observability): export opt-in business event Logs over OTLP
This commit is contained in:
commit
b4d849eb7f
80 changed files with 4168 additions and 38 deletions
|
|
@ -80,6 +80,8 @@ SESSION_COOKIE_SECURE=false
|
|||
|
||||
# Observability defaults require no Collector or tracing backend.
|
||||
# Use json in container deployments when stdout is collected centrally.
|
||||
# Opt in to protected metrics with health,info,metrics,prometheus; never use '*'.
|
||||
SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info
|
||||
SKILLHUB_TRACING_MODE=none
|
||||
SKILLHUB_LOG_FORMAT=json
|
||||
SKILLHUB_LOG_ASYNC_QUEUE_SIZE=1024
|
||||
|
|
@ -91,6 +93,29 @@ MANAGEMENT_OTLP_TRACING_ENDPOINT=
|
|||
SKILLHUB_OTLP_TIMEOUT=5s
|
||||
SKILLHUB_OTLP_COMPRESSION=gzip
|
||||
|
||||
# Independent, lossy business analytics. Default off; no receiver required.
|
||||
# Full catalog, Secret injection and Collector query example: docs/business-event-logs.md
|
||||
SKILLHUB_BUSINESS_LOGS_ENABLED=false
|
||||
# Managed by runtime.sh; unique names per installation on a shared Docker host.
|
||||
SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=false
|
||||
SKILLHUB_BUSINESS_LOGS_NETWORK=skillhub-business-logs
|
||||
SKILLHUB_BUSINESS_LOGS_PROJECT=skillhub-business-logs
|
||||
SKILLHUB_BUSINESS_LOGS_ENDPOINT=
|
||||
# URL-encoded name=value pairs, comma-separated; inject from deployment Secrets, never commit real values.
|
||||
SKILLHUB_BUSINESS_LOGS_HEADERS=
|
||||
SKILLHUB_BUSINESS_LOGS_TIMEOUT=3s
|
||||
SKILLHUB_BUSINESS_LOGS_COMPRESSION=gzip
|
||||
SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE=2048
|
||||
SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE=256
|
||||
SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY=1s
|
||||
SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT=5s
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS=3
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF=200ms
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF=1s
|
||||
SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked
|
||||
# Empty = all namespaces, INCLUDING private-resource IDs; never names/content.
|
||||
SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST=
|
||||
|
||||
# Zero-config runtime validation uses local storage.
|
||||
# Switch to `s3` and fill the fields below before a real production deployment.
|
||||
SKILLHUB_STORAGE_PROVIDER=local
|
||||
|
|
|
|||
12
.github/workflows/pr-scripts.yml
vendored
12
.github/workflows/pr-scripts.yml
vendored
|
|
@ -11,6 +11,11 @@ on:
|
|||
- 'compose.release.yml'
|
||||
- 'compose.release.analytics.yml'
|
||||
- 'compose.analytics-umami.yml'
|
||||
- 'compose.business-logs-collector.yml'
|
||||
- 'compose.release.business-logs.yml'
|
||||
- 'business-logs-collector.yaml'
|
||||
- 'docs/examples/business-event-logs/**'
|
||||
- 'docs/business-event-logs.md'
|
||||
- 'compose.storage.*.yml'
|
||||
- 'deploy/storage/**'
|
||||
- 'deploy/runtime-mirror-images.txt'
|
||||
|
|
@ -58,6 +63,13 @@ jobs:
|
|||
- run: bash scripts/tests/publish-cli-test.sh
|
||||
- run: bash scripts/tests/runtime-secret-test.sh
|
||||
- run: bash scripts/tests/runtime-analytics-test.sh
|
||||
- run: bash scripts/tests/runtime-business-logs-test.sh
|
||||
- run: bash scripts/tests/runtime-review-regression-test.sh
|
||||
- uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.19.0
|
||||
- run: helm dependency build charts/skillhub
|
||||
- run: python3 scripts/tests/test_business_logs_config.py
|
||||
- run: bash scripts/tests/validate-release-config-test.sh
|
||||
- run: bash scripts/tests/storage-compose-test.sh
|
||||
- run: bash scripts/tests/storage-compose-render-test.sh
|
||||
|
|
|
|||
19
Makefile
19
Makefile
|
|
@ -1,5 +1,6 @@
|
|||
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-github-sync dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke suite-bundle-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
DEV_DIR := .dev
|
||||
DEV_SERVER_PID := $(DEV_DIR)/server.pid
|
||||
DEV_WEB_PID := $(DEV_DIR)/web.pid
|
||||
|
|
@ -14,7 +15,7 @@ STAGING_WEB_URL := http://localhost
|
|||
STAGING_SERVER_IMAGE := skillhub-server:staging
|
||||
DEV_PROCESS := bash scripts/dev-process.sh
|
||||
DEV_SERVER_PREPARE := true
|
||||
DEV_SERVER_CMD := ./scripts/run-dev-app.sh
|
||||
DEV_SERVER_CMD := bash ../scripts/business-logs-demo.sh run-host ./scripts/run-dev-app.sh
|
||||
DEV_SERVER_SCANNER_ENV := SKILLHUB_SECURITY_SCANNER_ENABLED=true SKILLHUB_SECURITY_SCANNER_URL=$(DEV_SCANNER_URL) SKILLHUB_SECURITY_SCANNER_MODE=upload
|
||||
BACKEND_TEST_JAVA_OPTIONS ?= -XX:+EnableDynamicAgentLoading
|
||||
PARALLEL_BASE_REF ?= origin/main
|
||||
|
|
@ -25,6 +26,22 @@ DEV_COMPOSE := docker compose -p $(DEV_COMPOSE_PROJECT_NAME)
|
|||
STAGING_BASE_COMPOSE := docker compose -p $(STAGING_COMPOSE_PROJECT_NAME)
|
||||
STAGING_COMPOSE := $(STAGING_BASE_COMPOSE) -f docker-compose.yml -f docker-compose.staging.yml
|
||||
|
||||
.PHONY: business-logs-demo-up business-logs-demo-status business-logs-demo-records business-logs-demo-verify business-logs-demo-down
|
||||
business-logs-demo-up: ## 启动本地业务日志 Collector(BUSINESS_LOGS_BACKEND=host 或 container)
|
||||
sh scripts/business-logs-demo.sh up
|
||||
|
||||
business-logs-demo-status: ## 查看本地业务日志 Collector
|
||||
sh scripts/business-logs-demo.sh status
|
||||
|
||||
business-logs-demo-records: ## 输出 Collector 实收 JSON 行
|
||||
sh scripts/business-logs-demo.sh records
|
||||
|
||||
business-logs-demo-verify: ## 验证实收事件(默认 skill.download.prepared)
|
||||
sh scripts/business-logs-demo.sh verify
|
||||
|
||||
business-logs-demo-down: ## 停止本地 Collector,保留记录与网络
|
||||
sh scripts/business-logs-demo.sh down
|
||||
|
||||
help: ## 显示帮助
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
|
|
|
|||
21
business-logs-collector.yaml
Normal file
21
business-logs-collector.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Production forwarding template; configure a durable downstream outside this repo.
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
processors:
|
||||
memory_limiter:
|
||||
check_interval: 1s
|
||||
limit_mib: 64
|
||||
spike_limit_mib: 16
|
||||
exporters:
|
||||
otlphttp:
|
||||
endpoint: ${env:SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT}
|
||||
headers: ${env:SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS}
|
||||
service:
|
||||
pipelines:
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter]
|
||||
exporters: [otlphttp]
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
- **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式
|
||||
- **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy
|
||||
- **可观测性**:内置 Prometheus metrics exporter
|
||||
- **业务事件 Logs(默认关闭)**:`server.businessLogs.enabled`、`endpoint` 与 `headersSecretRef`(引用现有 Secret);
|
||||
其他预算/过滤配置使用 `server.extraEnv`,复用现有接收器,不增加 Collector 子 Chart。
|
||||
指标默认只暴露 health/info;`server.actuatorExposureInclude: health,info,metrics,prometheus` 可打开受保护的查询入口。
|
||||
Secret 创建、upgrade、产生事件、管理员查询与停用见[完整运行手册](../../docs/business-event-logs.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,23 @@ spec:
|
|||
value: {{ $profiles | quote }}
|
||||
- name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED
|
||||
value: {{ .Values.server.suiteReviewWritesEnabled | quote }}
|
||||
- name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE
|
||||
value: {{ .Values.server.actuatorExposureInclude | default "health,info" | quote }}
|
||||
{{- $businessLogs := .Values.server.businessLogs | default dict }}
|
||||
- name: SKILLHUB_BUSINESS_LOGS_ENABLED
|
||||
value: {{ $businessLogs.enabled | default false | quote }}
|
||||
- name: SKILLHUB_BUSINESS_LOGS_ENDPOINT
|
||||
value: {{ $businessLogs.endpoint | default "" | quote }}
|
||||
{{- with $businessLogs.headersSecretRef }}
|
||||
{{- if .name }}
|
||||
- name: SKILLHUB_BUSINESS_LOGS_HEADERS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .name | quote }}
|
||||
key: {{ .key | quote }}
|
||||
optional: false
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- name: SKILLHUB_GITHUB_SYNC_STAGING_CAPACITY_LIMIT_BYTES
|
||||
value: {{ .Values.githubSync.stagingCapacityLimitBytes | quote }}
|
||||
{{- if .Values.githubSync.enabled }}
|
||||
|
|
|
|||
|
|
@ -633,3 +633,25 @@ print('Helm application Secret render/rotation contracts passed')
|
|||
PY_SECRET_RENDER
|
||||
|
||||
echo "Helm configuration contract tests passed"
|
||||
|
||||
# Business Logs and metrics remain opt-in; credentials reference an existing Secret.
|
||||
render business-default "$CHART_DIR" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-default.yaml"
|
||||
grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "health,info"'
|
||||
grep -A1 -F 'name: SKILLHUB_BUSINESS_LOGS_ENABLED' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "false"'
|
||||
assert_rejected business-no-endpoint --set server.businessLogs.enabled=true
|
||||
assert_rejected actuator-wildcard --set-string 'server.actuatorExposureInclude=*'
|
||||
cat >"$TMP_DIR/business-values.yaml" <<'BUSINESS_VALUES'
|
||||
server:
|
||||
actuatorExposureInclude: health,info,metrics,prometheus
|
||||
businessLogs:
|
||||
enabled: true
|
||||
endpoint: https://receiver.example.com/v1/logs
|
||||
headersSecretRef:
|
||||
name: business-logs-credentials
|
||||
key: headers
|
||||
BUSINESS_VALUES
|
||||
render business-enabled "$CHART_DIR" -f "$TMP_DIR/business-values.yaml" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-enabled.yaml"
|
||||
grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'value: "health,info,metrics,prometheus"'
|
||||
grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'name: "business-logs-credentials"'
|
||||
grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'optional: false'
|
||||
echo 'Helm business Logs / metrics contracts passed'
|
||||
|
|
|
|||
|
|
@ -452,6 +452,27 @@
|
|||
"enabled": { "type": "boolean" },
|
||||
"replicaCount": { "type": "integer", "minimum": 1 },
|
||||
"suiteReviewWritesEnabled": { "type": "boolean" },
|
||||
"actuatorExposureInclude": { "enum": ["health,info", "health,info,metrics,prometheus"] },
|
||||
"businessLogs": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "endpoint", "headersSecretRef"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"endpoint": { "type": "string", "pattern": "^$|^https?://[^@?#]+/[^?#]+$" },
|
||||
"headersSecretRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": { "$ref": "#/definitions/optionalDnsLabel" },
|
||||
"key": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"if": { "properties": { "enabled": { "const": true } } },
|
||||
"then": { "properties": { "endpoint": { "minLength": 1 } } }
|
||||
},
|
||||
"image": { "$ref": "#/definitions/image" },
|
||||
"dependencyWait": {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -312,6 +312,18 @@ server:
|
|||
# Server instance supports typed review subjects; fresh non-rolling installs may enable it directly.
|
||||
suiteReviewWritesEnabled: false
|
||||
|
||||
# Opt in with health,info,metrics,prometheus. No wildcard exposure.
|
||||
actuatorExposureInclude: health,info
|
||||
|
||||
# Independent of tracing; optional OTLP Logs receiver. Other budgets use server.extraEnv.
|
||||
businessLogs:
|
||||
enabled: false
|
||||
endpoint: ""
|
||||
# Existing Secret containing URL-encoded OTLP header pairs. No inline credentials.
|
||||
headersSecretRef:
|
||||
name: ""
|
||||
key: headers
|
||||
|
||||
image:
|
||||
registry: ""
|
||||
tag: ""
|
||||
|
|
|
|||
20
compose.business-logs-collector.yml
Normal file
20
compose.business-logs-collector.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Optional production forwarder. No host port and no local file storage.
|
||||
name: ${SKILLHUB_BUSINESS_LOGS_PROJECT:-skillhub-business-logs}
|
||||
services:
|
||||
business-logs-collector:
|
||||
image: ${SKILLHUB_BUSINESS_LOGS_COLLECTOR_IMAGE:-otel/opentelemetry-collector-contrib:0.111.0}
|
||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||
restart: unless-stopped
|
||||
mem_limit: 128m
|
||||
cpus: 0.5
|
||||
environment:
|
||||
SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT:?configure business-logs/.env downstream endpoint first}
|
||||
SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS: ${SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS:-{}}
|
||||
volumes:
|
||||
- ./business-logs-collector.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||
networks:
|
||||
- business-logs
|
||||
networks:
|
||||
business-logs:
|
||||
external: true
|
||||
name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs}
|
||||
10
compose.release.business-logs.yml
Normal file
10
compose.release.business-logs.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Optional attachment: retain the backend's ordinary dependency network.
|
||||
services:
|
||||
server:
|
||||
networks:
|
||||
- default
|
||||
- business-logs
|
||||
networks:
|
||||
business-logs:
|
||||
external: true
|
||||
name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs}
|
||||
|
|
@ -117,7 +117,22 @@ services:
|
|||
SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:-PT16M}
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true}
|
||||
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
|
||||
SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info}
|
||||
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
|
||||
SKILLHUB_BUSINESS_LOGS_ENABLED: ${SKILLHUB_BUSINESS_LOGS_ENABLED:-false}
|
||||
SKILLHUB_BUSINESS_LOGS_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-}
|
||||
SKILLHUB_BUSINESS_LOGS_HEADERS: ${SKILLHUB_BUSINESS_LOGS_HEADERS:-}
|
||||
SKILLHUB_BUSINESS_LOGS_TIMEOUT: ${SKILLHUB_BUSINESS_LOGS_TIMEOUT:-3s}
|
||||
SKILLHUB_BUSINESS_LOGS_COMPRESSION: ${SKILLHUB_BUSINESS_LOGS_COMPRESSION:-gzip}
|
||||
SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE: ${SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE:-2048}
|
||||
SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE: ${SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE:-256}
|
||||
SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY: ${SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY:-1s}
|
||||
SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT: ${SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT:-5s}
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS:-3}
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF: ${SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF:-200ms}
|
||||
SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF:-1s}
|
||||
SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST: ${SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST-skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked}
|
||||
SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST: ${SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST:-}
|
||||
SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-json}
|
||||
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
|
||||
SKILLHUB_SERVICE_VERSION: ${SKILLHUB_SERVICE_VERSION:-unknown}
|
||||
|
|
|
|||
|
|
@ -667,7 +667,8 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `
|
|||
| 请求关联 | 响应头和日志中的 `X-Request-Id` / `request.id` |
|
||||
| 日志 | 文本或 ECS 风格 JSON,均输出到容器 stdout / stderr |
|
||||
| Trace | `none`、Micrometer + OTel SDK、或外部 Java Agent 三选一 |
|
||||
| 指标 | Spring Boot Actuator;Prometheus 是可选后端,不是 Trace 前置条件 |
|
||||
| 业务事实 | 默认关闭;生产 `runtime.sh business-logs-enable` / `business-logs-up`,本地 `make business-logs-demo-up`;[配置、实收查询、排障与停止](./business-event-logs.md) |
|
||||
| 指标 | 默认仅 health/info;`SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus` 后重建 server,使用管理员会话查询 |
|
||||
|
||||
### 10.1 通用配置
|
||||
|
||||
|
|
@ -693,7 +694,7 @@ JSON 日志使用以下稳定字段:
|
|||
|
||||
### 10.2 三种 Tracing 模式
|
||||
|
||||
三种模式只能选择一种,切换后需要重启:
|
||||
三种 Trace 模式只能选择一种,切换后需要重启。该限制不禁止独立配置业务 Logs;下表及本节的 endpoint 限制仅指 TRACE endpoint:
|
||||
|
||||
| 模式 | 适用场景 | 必需配置 |
|
||||
|------|----------|----------|
|
||||
|
|
|
|||
291
docs/business-event-logs.md
Normal file
291
docs/business-event-logs.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# Business-event Logs over OTLP / OTLP 业务事件日志
|
||||
|
||||
Default off. This is lossy analytics, NOT audit, billing, notification or installation confirmation. Use the official server image built from this change; no custom application code, vendor SDK or Collector is required. The receiver must support OTLP/HTTP protobuf Logs. / 默认关闭;用于有损分析,不替代审计、计费、可靠通知或安装确认。只需对应版本官方镜像与运维配置,Collector 可选。
|
||||
|
||||
## Choose a path / 选择路径
|
||||
|
||||
- Production: `runtime.sh business-logs-enable` connects an existing receiver; `business-logs-up` installs an optional private Collector with an explicit downstream. See [production integration](#production-integration).
|
||||
- Local trial: `make business-logs-demo-up` supports `make dev-server` or a containerized backend. See [local trial](#local-trial).
|
||||
- Both paths include configuration updates, new-event queries, metrics, troubleshooting and cleanup. Headers stay in deployment-owned files / 两条路径均包含配置更新、新事件查询、指标、排障与清理,请求头保留在部署侧。
|
||||
|
||||
## Event catalog / 事件目录 (contract version 1)
|
||||
|
||||
Every row requires `skillhub.event.name`, `skillhub.skill.id`, `skillhub.namespace.id`, `skillhub.visibility`, `skillhub.source`, `skillhub.actor.type`, `log.record.uid` and producer occurrence `Timestamp`. IDs are numeric except the local opaque human `user.id` and UUID UID. Actor type is `user` (including anonymous/unknown user requests) or `system`. A known human adds `user.id`; anonymous/unknown and system actors omit it. / 所有事件都有上述字段;主体类型为 user(含匿名或未知请求)或 system;已知人类操作者额外使用本实例不透明 `user.id`,匿名、未知及系统主体省略该字段。
|
||||
|
||||
| Event | When / 时机 | Extra fields / 额外字段 | Actor and source / 操作者与来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| `skill.publication.effective` | Actual transition into PUBLISHED, exported only after commit / 实际发布且事务提交 | `skillhub.version.id` | Auto-publication: submitting privileged human, `auto_publish`; internal forced publication: `system`, no `user.id`. PRIVATE confirmation: confirmer, `private_confirm`. Review/promotion approval: actual approver, `review` / `promotion`, NOT necessarily uploader or owner / 审核或提升时为实际批准人而非上传者 |
|
||||
| `skill.download.prepared` | Authorized PUBLISHED request has built its download result and updated existing counters; before stream opens/redirect / 已授权的已发布版本已构建下载结果,尚未打开流或跳转 | `skillhub.version.id` | Current authenticated local user, otherwise anonymous (`user` without `user.id`); `download` / 当前调用人,否则 user 类型且省略 ID |
|
||||
| `skill.review.approved` | Existing content-version review approved, after commit / 内容版本审核通过并提交 | `skillhub.version.id`, `skillhub.review.id` | Actual reviewer; `review` / 实际审核人 |
|
||||
| `skill.review.rejected` | Existing content-version review rejected, after commit / 内容版本审核拒绝并提交 | `skillhub.version.id`, `skillhub.review.id` | Actual reviewer; `review`; no reason/comment / 不导出拒绝原因 |
|
||||
| `skill.promotion.approved` | Initial or update promotion approved, after commit / 首次或更新提升批准并提交 | `skillhub.promotion.id` | Actual approver; `promotion`; skill/namespace refer to SOURCE, not derived target / 技能及空间指来源而非全局目标 |
|
||||
| `skill.promotion.rejected` | Promotion rejected, after commit / 提升拒绝并提交 | `skillhub.promotion.id` | Actual reviewer; `promotion`; SOURCE scope; no reason / 来源范围,不导出原因 |
|
||||
| `skill.status.changed` | Archive/unarchive actually changes status, after commit / 归档或取消归档状态实际变化并提交 | `skillhub.status.old`, `skillhub.status.new` | Owner, namespace manager or authorized admin invoking operation; `governance` / 实际执行的所有者、空间管理者或管理员 |
|
||||
| `skill.visibility.changed` | Effective access-scope change: immediate narrowing OR approved expansion, after commit / 缩小范围立即生效,扩大范围审核通过后生效;均提交后导出 | `skillhub.visibility.old`, `skillhub.visibility.new` | Narrowing requester or expansion approver; `visibility` / 缩小时请求人,扩大时批准人 |
|
||||
| `skill.version.yanked` | Existing published-version yank, after commit / 已发布版本撤回分发并提交 | `skillhub.version.id` | Actual owner/manager/admin; `governance` / 实际执行人 |
|
||||
|
||||
Publication covers all four producers: `SkillPublishService`, `SkillReviewSubmitService`, `ReviewService`, `PromotionService`. The reserved `builtin-skill-publisher` identity is classified as `system` even through the ordinary privileged auto-publication path (bootstrap verifies that it is a system account). Approval may correctly create TWO different facts: publication plus review/promotion approval. These are not duplicate records. Promotion approval's publication references the TARGET skill/version/namespace; its promotion fact references the SOURCE. Namespace filtering may therefore keep only one. / 发布覆盖四条生产路径;内置技能的保留系统身份即便走普通特权直发,也标记为 system,启动器会校验其系统账号属性;批准同时产生发布及审核/提升两个不同事实,不是重复。提升的发布事件指目标,提升决定事件指来源,空间过滤可能只保留其一。
|
||||
|
||||
Timestamp is captured immediately before the producer publishes its domain event, not `publishedAt`, transaction commit time or async-send time. Transaction synchronization captures the record and valid span identity at event dispatch, admits only after the enclosing physical transaction commits, and suppresses records when that physical transaction rolls back. Outside a transaction (notably the shared download service), admission is immediate. No entity lookup happens in the telemetry listener. / 时间是生产点发布领域事件前的时刻,不是提交或发送时刻;事务内仅物理事务提交后入队,物理事务回滚不发;无事务下载直接入队。
|
||||
|
||||
Savepoint exclusion: rollback suppression does NOT cover `PROPAGATION_NESTED` savepoint rollback. Its synchronization belongs to the enclosing physical transaction: a nested event can still export when the outer transaction commits, even though the nested database write was rolled back. Do not use this pipeline as evidence of savepoint-level persistence; producers requiring that guarantee must not dispatch from nested/savepoint work. No current included production path configures NESTED propagation. `REQUIRES_NEW` is a separate physical transaction: its committed event exports even if the suspended outer transaction later rolls back; the outer event is suppressed. / 保存点例外:不保证 `PROPAGATION_NESTED` 回滚时抑制事件;外层物理事务提交仍可导出已回滚保存点内的事件。需要该保证的生产点不得在保存点内发布;当前纳入路径未配置 NESTED。`REQUIRES_NEW` 独立提交不受外层之后回滚影响。
|
||||
|
||||
Download means prepared, NOT delivered, complete, installed or executed. Web/v1/CLI share the producer; authorized previews of unpublished versions and review packages do not emit. Compat redirects alone, Suite plans, resolve/detail polling, direct object-storage access, URL reuse and client caches are not new registry download-prepared facts. / 下载准备不等于送达、传输完成、安装或执行;未发布预览、审核包、套装计划、查询轮询不计入。对象存储直取、URL 重用及缓存可绕过注册表。
|
||||
|
||||
Visibility facts cover the existing dedicated `SkillVisibilityService` event producers. Visibility assigned as part of uploading/publishing a content version has no separate visibility event in the existing domain model; its final value is included on the publication fact. / 可见范围事实覆盖独立范围调整的既有生产点;内容上传或发布时顺带赋予的可见范围没有独立领域事件,只在发布事实中携带最终值。
|
||||
|
||||
Deferred: metadata/avatar review decisions without an existing decision event (effective visibility changes ARE included), hide/unhide, promotion revocation, review submission/withdrawal, Suite events, searches/views, transfer completion, installation and durable replay. Existing audit/notification/search listeners and authorization rules are unchanged. Legacy events constructed without a producer snapshot are intentionally ignored, not enriched from a database at export time. / 其余事件分期;不变更既有审计、通知、搜索或授权;缺少生产点快照的旧式事件不导出,不在导出时查库补全。
|
||||
|
||||
## Representation and data boundary / 数据模型与边界
|
||||
|
||||
- Dedicated scope `com.iflytek.skillhub.business`, instrumentation version `1`; Spring Boot 3.2.3 BOM / OTel Java 1.31.0 unchanged. `skillhub.event.name` is a documented custom attribute because 1.31 lacks `setEventName`. This is standard OTLP LogRecord transport, not a claim of implementing the latest experimental OTel Events semantic conventions. `log.record.uid` borrows the experimental uniqueness convention; contract version 1 locks its meaning here, not an unpinned global semconv schema.
|
||||
- Resource is ONLY `service.name` (existing `spring.application.name`), `service.version` (existing SkillHub setting), `deployment.environment` (existing SkillHub environment). No host/cloud/process/resource autodetection. Occurrence uses Timestamp; ObservedTimestamp is left to SDK. Body is empty.
|
||||
- Only an already-valid current OTel span context is associated, including unsampled spans. No span is created and no MDC/request ID is converted into trace identity. Trace absence or sampling never filters business records; an associated unsampled trace may not be queryable.
|
||||
- The sole mapper copies explicit fixed fields, never an event/entity map. Maximum 16 attributes, 256 UTF-16 code units per string (SDK limit as defense-in-depth), three bounded Resource strings, no arbitrary arrays/maps. Unknown event types/sources are ignored. IDs of private skills and namespaces ARE included by default when this instance-level feature is enabled. Empty namespace allowlist means all namespaces. This keeps review/governance analytics interpretable without exporting names or performing enrichment queries. IDs remain linkable/personal data, NOT anonymized; restrict the allowlist, receiver access, retention and destination accordingly.
|
||||
- Never exported: skill/namespace names or slugs, package/body/metadata content, titles, summaries, rejection comments/reasons, submitter/owner identity, email/name, query terms, tokens/passwords/cookies, signed URLs, IP, UA, arbitrary request headers, request IDs, MDC or baggage. Authentication headers are transport-only, not attributes.
|
||||
|
||||
中文:独立记录器,不桥接应用日志,不更改 Trace;Resource 仅包含服务名、版本及环境。白名单最多 16 个属性、字符串最多 256 个 UTF-16 单元。默认包括私有技能及空间的 ID,但不包括名称/内容;ID 仍可关联个人或资源,须按敏感数据管理。命名空间白名单建议上线前明确限制。正文、原因、密钥、签名 URL、IP/UA、任意请求头和 baggage 一律不输出。
|
||||
|
||||
## Complete configuration / 完整配置
|
||||
|
||||
All settings are startup-only: recreate/restart the server after changes. Property prefix is `skillhub.observability.business-logs.`. Environment suffixes below use `SKILLHUB_BUSINESS_LOGS_`. `application.yml` and release Compose explicitly map them; manual SDK builders DO NOT automatically read `OTEL_*`. / 所有配置启动时读取,需重建/重启;以下环境变量使用统一前缀,不自动读取 `OTEL_*`。
|
||||
|
||||
| Property suffix / 属性 | Env suffix / 环境变量后缀 | Default / 默认 | Meaning / 范围与含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | `ENABLED` | `false` | Opt-in, independent of tracing / 独立开关 |
|
||||
| `endpoint` | `ENDPOINT` | empty | Required when enabled; full HTTP(S) Logs URL, used AS-IS, normally `/v1/logs`; NOT `/v1/traces`. No userinfo/query/fragment or known metadata literal targets / 启用必填完整 Logs URL,不追加路径 |
|
||||
| `headers` | `HEADERS` | empty | At most 16 comma-separated URL-encoded `name=value` pairs; total 8192 characters; ASCII values, no controls / 最多 16 对 URL 编码请求头,总长 8192;使用 Secret |
|
||||
| `timeout` | `TIMEOUT` | `3s` | SDK HTTP timeout; 100ms..30s / 有界超时 |
|
||||
| `compression` | `COMPRESSION` | `gzip` | `gzip` or `none` |
|
||||
| `max-queue-size` | `MAX_QUEUE_SIZE` | `2048` | 1..65536 admitted records INCLUDING queued and in-flight, not bytes or precommit callbacks / 接纳后的队列及在途记录,不按字节,不包括提交前回调 |
|
||||
| `max-export-batch-size` | `MAX_EXPORT_BATCH_SIZE` | `256` | 1..min(4096,queue size) / 每批上限 |
|
||||
| `schedule-delay` | `SCHEDULE_DELAY` | `1s` | 10ms..60s; SDK batch schedule / 批处理调度间隔 |
|
||||
| `shutdown-timeout` | `SHUTDOWN_TIMEOUT` | `5s` | 100ms..30s; maximum caller wait for graceful drain / 退出等待上限 |
|
||||
| `retry-max-attempts` | `RETRY_MAX_ATTEMPTS` | `3` | 1..5 INCLUDING first request; 1 disables retry / 含首次请求,1 为不重试 |
|
||||
| `retry-initial-backoff` | `RETRY_INITIAL_BACKOFF` | `200ms` | 1ms..5s, no greater than max backoff / 初始退避 |
|
||||
| `retry-max-backoff` | `RETRY_MAX_BACKOFF` | `1s` | 1ms..10s, SDK exponential multiplier 2 with jitter / 最大退避 |
|
||||
| `event-allowlist` | `EVENT_ALLOWLIST` | all 9 catalog names | Comma-separated exact names; empty exports none; unknown name fails startup / 精确事件名,空为不导出,未知值启动失败 |
|
||||
| `namespace-allowlist` | `NAMESPACE_ALLOWLIST` | empty | Empty = all including private; otherwise up to 1000 positive numeric namespace IDs, comma-separated / 空为全部(含私有),否则最多 1000 个正整数 ID |
|
||||
|
||||
Related existing Resource settings: `spring.application.name` (`skillhub`, environment `SPRING_APPLICATION_NAME`), `skillhub.observability.service-version` (`SKILLHUB_SERVICE_VERSION`, `unknown`), `skillhub.observability.service-environment` (`SKILLHUB_SERVICE_ENVIRONMENT`, `local`; release Compose `production`). These strings are bounded to 256 code units. Do not put credentials in service identity. / 沿用既有服务标识配置;不要在服务标识中放凭证。
|
||||
|
||||
A generic OTLP base endpoint normally appends a signal path in OTel autoconfiguration; THIS setting is Logs-specific, uses the given URL unchanged, and requires an explicit non-root path. Invalid enabled configuration fails startup without echoing endpoint/header values. Localhost/private-network receivers are valid. Destination is trusted operator configuration, not an end-user setting: use egress firewall/NetworkPolicy and DNS controls to allow only the authorized receiver and block cloud metadata, including aliases/rebinding. The managed 1.31 OkHttp sender follows redirects and exposes no redirect switch on its public Logs exporter builder: configure the receiver not to redirect and enforce destination/HTTPS restrictions at egress. Literal validation is not a complete SSRF firewall. / 本配置不是通用 base endpoint,不追加信号路径;允许自托管内网目标。1.31 发送器会跟随重定向,公开 builder 无关闭开关,接收端应禁止重定向,并配合出口/DNS/HTTPS 策略;字面量校验不能替代防火墙。
|
||||
|
||||
<a id="production-integration"></a>
|
||||
|
||||
## Production integration / 生产接入
|
||||
|
||||
### Retrieve and apply / 获取与应用
|
||||
|
||||
使用包含本功能的同一版本脚本、Compose 和镜像。正式发布后同时传入 `--ref <tag> --version <tag>`,把 `REF`、`VERSION` 固定为同一发布标签。开发验证可使用 `main` / `edge`。`--version` 只选择镜像标签;`--ref`(或环境变量 `SKILLHUB_REF`,默认 `main`)独立选择文件来源。
|
||||
|
||||
```sh
|
||||
REF=main
|
||||
VERSION=edge
|
||||
RUNTIME_HOME="$HOME/skillhub-runtime"
|
||||
curl -fsSL "https://raw.githubusercontent.com/iflytek/skillhub/$REF/scripts/runtime.sh" -o runtime.sh
|
||||
sh runtime.sh ps --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
# 在仓库外编辑 $RUNTIME_HOME/.env.release:数据库、存储、管理员和公开地址。
|
||||
chmod 600 "$RUNTIME_HOME/.env.release"
|
||||
sh runtime.sh up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
```
|
||||
|
||||
默认 `SKILLHUB_BUSINESS_LOGS_ENABLED=false`,不启动 Collector,不创建业务日志网络,也不向接收端发送请求。已部署实例升级时先拉取包含本功能的镜像,再用 `up` 重建。仅 `restart` 不读取新的 Compose 环境变量。
|
||||
|
||||
已有兼容 OTLP/HTTP protobuf Logs 接收端时,在部署侧 `.env.release` 配置过滤范围与请求头,然后执行:
|
||||
|
||||
```sh
|
||||
sh runtime.sh business-logs-enable --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" \
|
||||
--logs-endpoint https://receiver.example.com/v1/logs
|
||||
# 同一 Docker 主机上的接收容器可使用已有私有网络;脚本自动检查或创建网络并重建 server。
|
||||
# sh runtime.sh business-logs-enable ... --logs-network receiver-private --logs-endpoint http://receiver:4318/v1/logs
|
||||
```
|
||||
|
||||
`--logs-endpoint` 是完整 Logs URL,原样使用,通常以 `/v1/logs` 结尾。已有远端接收器不需要本地 Collector。容器接收器在其自身部署中声明共享的外部网络;SkillHub 侧由小型 `compose.release.business-logs.yml` 自动接入。正常 `up` 会保留这个 overlay。每个实例使用独立网络名与项目名。
|
||||
|
||||
所有请求头保留在部署侧。应用的 `SKILLHUB_BUSINESS_LOGS_HEADERS` 是 URL 编码的 `name=value` 对,以逗号分隔。`+` 解码为空格;逗号、等号、百分号和字面量加号需编码。真实值不进入仓库、命令行、截图或共享日志。不要把加载了密钥的 `docker compose config` 输出到日志。跨信任边界使用 HTTPS 与接收端鉴权。私有 CA 使用部署提供的 JVM truststore;mTLS 可由本地 Collector / 代理终止。
|
||||
|
||||
Docker Secret 也可挂载为 `/run/secrets/skillhub.observability.business-logs.headers`,并通过部署 overlay 设置 `SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/`。同时保持 `SKILLHUB_BUSINESS_LOGS_HEADERS` 为空。Secret 文件放在仓库外。
|
||||
|
||||
### Optional production Collector / 可选生产 Collector
|
||||
|
||||
此路径使用独立 Compose 项目、外部私有网络、128 MiB 内存上限和 0.5 CPU。Collector 不发布主机端口。生产配置只有明确的 `otlphttp` 下游出口,没有 JSON 文件存储。
|
||||
|
||||
```sh
|
||||
sh runtime.sh business-logs-up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
# 首次运行创建 business-logs/.env(0600),提示填写下游后退出,不启动半配置的 Collector。
|
||||
# 在部署侧编辑 $RUNTIME_HOME/business-logs/.env:
|
||||
# SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://receiver.example.com
|
||||
# SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'
|
||||
# 有鉴权时把 headers 改成部署侧 JSON 对象,例如 Authorization 键;不在命令行传值。
|
||||
sh runtime.sh business-logs-up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
sh runtime.sh business-logs-ps --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
```
|
||||
|
||||
下游设置是 OTLP base URL;Collector 自动追加 `/v1/logs`。首次创建独立 env 时,`business-logs-up --logs-network <name>` 可指定网络;已有 env 的网络与密钥保持不变,后续以该文件为准。应用指向 `http://business-logs-collector:4318/v1/logs`。脚本从同一 `REF` 获取根目录下的 `compose.business-logs-collector.yml`、`business-logs-collector.yaml` 和网络 overlay,自动接网并重建 server。下游负责持久存储、查询、访问控制和保留期。默认模板使用内存队列;生产容量与持久发送队列由部署方按接收系统配置。
|
||||
|
||||
更新 `.env.release` 后,重跑 `business-logs-enable` 应用地址、过滤或指标配置。更新 `business-logs/.env` 后,重跑 `business-logs-up` 应用下游配置。脚本保留已有文件中的密钥,不覆盖 `.env`。需要自定义 Collector 管道时,维护部署方自己的 Compose / 配置文件;脚本会更新下载的模板。
|
||||
|
||||
`--aliyun` 使用扁平 OSS 根目录。发布流程须将上述三个新文件与原有 `runtime.sh`、`compose.release.yml`、`.env.release.example` 一起上传,版本与镜像一致。OSS 根目录不是按 Git ref 划分的版本仓库,发布方负责匹配。
|
||||
|
||||
### Produce, query and stop / 产生、查询与停止
|
||||
|
||||
以超级管理员登录,发布一个新版本,或通过 `/api/v1/skills/<namespace>/<slug>/download` 下载已发布技能。没有历史补发。接收端按 `skillhub.event.name=skill.publication.effective` / `skill.download.prepared` 查询新时间窗口,查看 `skillhub.skill.id`、`skillhub.namespace.id`、`log.record.uid`。OTLP 是写入协议,没有统一的读 API;生产查询使用配置的下游系统自身的查询界面或 API。下文的本地路径提供可直接执行的 JSON 查询。
|
||||
|
||||
```sh
|
||||
sh runtime.sh business-logs-logs --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
# Ctrl-C 退出跟随日志。检查固定告警和接收端健康,不输出部署密钥。
|
||||
sh runtime.sh business-logs-disable --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
sh runtime.sh business-logs-down --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION"
|
||||
# 再启动:已有 SkillHub 依赖先 up,再 business-logs-up;已有下游数据和网络保留。
|
||||
```
|
||||
|
||||
`business-logs-disable` 重建 server 并关闭导出,保留已配置的私有网络连接。之后运行 `business-logs-enable` 即可恢复保存的接收端配置,无需再传网络参数。`business-logs-down` 只停止可选 Collector;单独停止它时应用仍会尝试导出。`down` 停止 SkillHub,保留数据卷。已配置业务日志接收端或可选 Collector 时,`clean` 也保留部署环境、密钥和 Collector 文件。彻底删除前先停两个项目,备份部署配置,再由运维显式删除自己创建的项目和网络。
|
||||
|
||||
### Helm endpoint and Secret / Helm 接入
|
||||
|
||||
复用已有接收器,不安装 Collector 子 Chart。先在目标 namespace 创建 Secret:`kubectl create secret generic business-logs-credentials --from-file=headers=/secure/otlp-headers.txt -n skillhub`。文件内容为应用使用的 URL 编码头,不是 Collector 的 JSON 格式。此文件由部署方管理。
|
||||
|
||||
```yaml
|
||||
server:
|
||||
actuatorExposureInclude: health,info,metrics,prometheus
|
||||
businessLogs:
|
||||
enabled: true
|
||||
endpoint: https://receiver.example.com/v1/logs
|
||||
headersSecretRef:
|
||||
name: business-logs-credentials
|
||||
key: headers
|
||||
extraEnv:
|
||||
- name: SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST
|
||||
value: "7,12"
|
||||
```
|
||||
|
||||
将片段保存为部署侧 `business-logs-values.yaml`。执行 `helm upgrade --install skillhub charts/skillhub -n skillhub -f production-values.yaml -f business-logs-values.yaml`,再执行 `kubectl rollout status deployment/skillhub-server -n skillhub`。按上面的方式产生新事件并在下游查询。Secret 轮换后执行 `kubectl rollout restart deployment/skillhub-server -n skillhub`。停用时将 `enabled` 改为 `false` 后再次 upgrade;现有 Secret 和下游数据保留。其它预算、过滤和 Resource / Trace 配置使用 `server.extraEnv`,不重复内建变量。
|
||||
|
||||
Helm 指标查询直接连接 server Service,不经过 Web / ingress。在一个终端保持端口转发(示例 release / namespace 均为 `skillhub`,Service 端口为默认的 8080;自定义名称或端口时对应替换):
|
||||
|
||||
```sh
|
||||
kubectl port-forward -n skillhub service/skillhub-server 18080:8080
|
||||
```
|
||||
|
||||
在另一个终端设置 `BASE=http://127.0.0.1:18080`,然后执行下文“指标启用与管理员查询”的登录和查询命令,登录与指标请求都使用这个转发地址。无需添加 ingress 路由。关闭转发后,本地访问结束。
|
||||
|
||||
<a id="local-trial"></a>
|
||||
|
||||
## Local trial / 本地试用
|
||||
|
||||
需要 Docker Compose、JDK 21、Maven wrapper 和 jq。从源码根目录执行。示例 Collector 固定为 contrib `0.111.0`,仅发布回环地址。JSON 追加写入项目独立的本地命名卷,没有保留策略,仅用于临时验证。
|
||||
|
||||
### Host Java process / 主机 Java 进程
|
||||
|
||||
```sh
|
||||
make dev
|
||||
make business-logs-demo-up
|
||||
make dev-server
|
||||
```
|
||||
|
||||
`business-logs-demo-up` 保存本地试用标记。`make dev-server` 自动使用 `http://127.0.0.1:4318/v1/logs` 并打开业务日志;无需编辑应用源码或手工设置接收地址。后端已运行时重新启动它。需要其它端口时,对整个试用过程统一设置 `SKILLHUB_BUSINESS_LOGS_DEMO_PORT`;需要并行实例时还统一设置 `SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT`、`SKILLHUB_BUSINESS_LOGS_NETWORK` 和 `BUSINESS_LOGS_DEMO_STATE_DIR`。
|
||||
|
||||
### Containerized backend / 容器后端
|
||||
|
||||
先用生产路径启动一个独立试用实例。它使用同一份 release Compose 和应用配置。然后从源码根目录设置该实例的环境文件和项目名:
|
||||
|
||||
```sh
|
||||
export BUSINESS_LOGS_BACKEND=container
|
||||
export BUSINESS_LOGS_BACKEND_PROJECT=skillhub-local-trial
|
||||
export BUSINESS_LOGS_BACKEND_ENV_FILE="$HOME/skillhub-local-trial/.env.release"
|
||||
# BUSINESS_LOGS_BACKEND_PROJECT 必须与已启动实例的 COMPOSE_PROJECT_NAME 相同。
|
||||
make business-logs-demo-up
|
||||
```
|
||||
|
||||
Makefile 自动创建试用私有网络,通过同一个 release 网络 overlay 重建 server,并使用 `http://business-logs-collector:4318/v1/logs`。保留这些变量,以便后续命令操作同一实例。需要指定不同版本的基础 Compose 时设置 `BUSINESS_LOGS_BACKEND_COMPOSE`。此模式的启用是本地试用状态;正常生产 `runtime.sh up` 仍以 `.env.release` 为准。
|
||||
|
||||
### Query and cleanup / 查询与清理
|
||||
|
||||
在本地界面登录管理员,发布新版本或下载已发布技能,然后执行:
|
||||
|
||||
```sh
|
||||
make business-logs-demo-status
|
||||
make business-logs-demo-records
|
||||
make business-logs-demo-verify
|
||||
# 默认验证 skill.download.prepared;也可指定其它已产生的事件。
|
||||
make business-logs-demo-verify BUSINESS_LOGS_EXPECT_EVENT=skill.publication.effective
|
||||
# 仅取 JSON 行进行结构化查询:
|
||||
make -s business-logs-demo-records | jq -r '.resourceLogs[].scopeLogs[].logRecords[] | (.attributes | map({key:.key,value:(.value.stringValue // .value.intValue // .value.boolValue)}) | from_entries) as $a | [$a["skillhub.event.name"], $a["skillhub.skill.id"], $a["log.record.uid"]] | @tsv'
|
||||
make business-logs-demo-down
|
||||
```
|
||||
|
||||
`records` 输出实际 JSON 行;`verify` 在缺少预期事件时返回非零。`up` 重建 Collector 以应用配置,命名卷与追加模式保留已有记录。`down` 停止进程,再次 `up` 后仍可查询原记录。主机进程模式停止后还需重启 Java 进程以关闭导出;容器模式自动重建并关闭导出。试用结束后及时停止;需要保留的记录先保存到仓库外。彻底删除时先停止后端,再用该试用项目名执行 `docker compose -p <demo-project> -f docs/examples/business-event-logs/compose.yaml down -v`,显式删除试用卷,最后删除已无容器连接的试用网络。命名卷不是生产存储或可靠投递保证。
|
||||
|
||||
### Metrics enablement and admin query / 指标启用与管理员查询
|
||||
|
||||
默认只暴露 `health,info`。即使已有业务事件,管理员访问 metrics / prometheus 也会得到 404。Compose 在 `.env.release` 设置 `SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus`,然后通过 `business-logs-enable`(收集启用时)或正常 `up` 重建 server。Helm 使用上面的 `server.actuatorExposureInclude`。主机 Java 进程则在启动前导出同名变量。不要使用 `*`。
|
||||
|
||||
源代码验证先执行 `make build-backend`,再执行 `docker build -t skillhub-server:local -f server/Dockerfile server`,并让 runtime 的 `--server-image skillhub-server --version local --ref <对应源码ref>` 指向新镜像。正式镜像升级使用发布标签,不依赖旧镜像中的新配置。
|
||||
|
||||
可复用浏览器中的超级管理员会话。命令行查询时,在仓库外准备仅本人可读的登录 JSON 文件,内容为管理员 username / password。以下命令不打印密码或 Cookie:
|
||||
|
||||
```sh
|
||||
BASE=${BASE:-http://127.0.0.1:8080}
|
||||
QUERY_DIR=$(mktemp -d)
|
||||
chmod 700 "$QUERY_DIR"
|
||||
# LOGIN_JSON=/secure/admin-login.json,文件 mode 600;由部署方创建。
|
||||
curl -fsS -c "$QUERY_DIR/cookies" "$BASE/api/v1/auth/methods" -o /dev/null
|
||||
CSRF=$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$QUERY_DIR/cookies")
|
||||
curl -fsS -b "$QUERY_DIR/cookies" -c "$QUERY_DIR/cookies" -H "X-XSRF-TOKEN: $CSRF" \
|
||||
-H 'Content-Type: application/json' --data-binary @"$LOGIN_JSON" "$BASE/api/v1/auth/local/login" -o /dev/null
|
||||
curl -fsS -b "$QUERY_DIR/cookies" "$BASE/actuator/metrics/skillhub.business.logs.enqueued"
|
||||
curl -fsS -b "$QUERY_DIR/cookies" "$BASE/actuator/prometheus"
|
||||
rm -rf "$QUERY_DIR"
|
||||
```
|
||||
|
||||
超级管理员可查询这两个指标入口。普通用户返回 403,匿名请求返回 401。Prometheus 沿用已有 `AUDITOR` 权限;单项 metrics 仅超级管理员。未启用业务导出时不会创建这些业务计数器;启用但未产生事件时计数为零。计数器是当前进程累计值,重启后归零,含义见下表。计数器反映应用接纳 / SDK 确认,不替代下游实收查询。
|
||||
|
||||
## Reliability, metrics and troubleshooting / 可靠性、指标与排障
|
||||
|
||||
Business threads only map fixed bounded fields, capture context, register a transaction callback and attempt nonwaiting SDK admission. No DB enrichment or network IO is performed by the adapter on business threads. Standard BatchLogRecordProcessor handles a bounded record queue; a conservative admission permit counts queued PLUS in-flight records to expose exact queue-full drops without SDK internals. Only SDK exporters implement network retry (retryable HTTP status/IO); no caller-runs fallback. Batch export budget is `(timeout + retry-max-backoff) * retry-max-attempts + 1s`; receiver `Retry-After` and sender behavior still depend on the managed SDK. / 业务线程仅做固定字段映射及非等待入队,不发送网络请求;队列预算包含在途,重试交标准 SDK。
|
||||
|
||||
Each export has an owned completion guard: SDK completion OR the export-budget deadline, whichever wins, releases admission permits exactly once. An abandoned SDK result (including 1.31's truncated non-2xx response-body callback failure) becomes one failed batch at the deadline. Late SDK success/failure cannot add permits or change counters. Deadlines run on one daemon scheduler, canceled tasks are removed, and closing fails outstanding guards and stops the scheduler. This bounds local accounting, not remote cancellation or delivery: a timed-out request might still reach the receiver. / 每批导出由 SDK 完成或预算到期中先发生者结束,许可只释放一次;SDK 遗失完成信号也会计一次失败,迟到结果不重复释放或计数。关闭时结束守卫并停止调度器;本地超时不代表远端已撤销。
|
||||
|
||||
Loss is expected on commit-to-listener/admission gaps, crashes, forced exits, queue saturation, timeout or exhausted retry, and incomplete shutdown. No disk spool, outbox, replay, backfill or exactly-once. Ambiguous acknowledgement can duplicate the same UID; deduplicate by `log.record.uid`, not `(skill,version)` (multiple legitimate downloads), request ID or trace ID. Mapping a new domain-event dispatch creates a new UID; idempotency of repeated business operations is unchanged. / 提交到入队间隙、崩溃、满队列及重试耗尽都可能漏报;超时重试可能重复,接收方按 UID 去重,不按技能版本或 Trace 去重。
|
||||
|
||||
The queue budget starts at after-commit admission. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event until completion, outside that SDK budget. Avoid unbounded bulk work in one outer transaction; this configuration is not a process-wide heap cap. / 队列预算从提交后接纳开始;提交前每个事件的有界快照由事务回调保留,不计入 SDK 预算。不要在单个外层事务里执行无界批量操作,这不是整个进程的堆内存上限。
|
||||
|
||||
Graceful shutdown stops new admission, gives SDK drain a `shutdown-timeout` wait budget, then shuts down the exporter to cancel pending network work. Local cleanup and scheduling add some overhead; this is not a hard-real-time deadline. Remaining records may be lost; daemon completion may continue briefly. This differs deliberately from the draft RFC's discard-unsent-on-disable wording: configuration is NOT hot-reloaded; setting `enabled=false` takes effect on restart, whose old process gets a bounded drain opportunity. Already accepted receiver data cannot be recalled. / 正常退出停止接纳并限时等待排空,另有本地清理和调度开销,不是硬实时期限;剩余记录可丢失。关闭开关需重启,旧进程会限时发送,不承诺立即撤销已入队或接收的数据。
|
||||
|
||||
OTel 1.31 treats all HTTP 2xx responses as full success and does not expose `partial_success` rejected counts. `exported` means SDK/receiver ACK, NOT downstream storage confirmation. Do not replay whole partially accepted batches. Partial rejection diagnostics and durable retries belong at the receiver; this change does not upgrade the BOM. / 1.31 不读取部分拒收数量;成功指标不代表最终落库,不整批重发部分接收的数据。
|
||||
|
||||
Micrometer names on the existing protected Actuator metrics surface (Prometheus renders dots as underscores and appends `_total` for counters, e.g. `skillhub_business_logs_enqueued_total`):
|
||||
|
||||
| Metric | Unit / 含义 |
|
||||
| --- | --- |
|
||||
| `skillhub.business.logs.enqueued` | Records admitted / 已接纳记录 |
|
||||
| `skillhub.business.logs.dropped{reason=queue_full}` | Records refused because outstanding budget is full / 满队列丢弃 |
|
||||
| `skillhub.business.logs.dropped{reason=shutdown}` | New records refused during closing / 退出时拒绝新记录 |
|
||||
| `skillhub.business.logs.errors` | Adapter mapping/admission/lifecycle exceptions and contained exporter dispatcher exceptions, swallowed / 适配器及已隔离的导出回调异常(不传播) |
|
||||
| `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws, exceeds the export budget, or is still outstanding at close / 最终失败、超时或关闭时仍未结束的批次 |
|
||||
| `skillhub.business.logs.exported` | Records in SDK-successful batches; may include receiver partial rejections / SDK 成功记录,含部分拒收盲区 |
|
||||
|
||||
No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads: `Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health`. Raw SDK diagnostics can echo receiver error bodies or transport exception text containing credentials; throttling or ERROR level alone is NOT safe. Shipped `logback-spring.xml` therefore installs `BusinessLogsDiagnosticsFilter`, which drops `io.opentelemetry.*` / `okhttp3.*` events (including those routed from JUL by Spring Boot) only on the business exporter's own network threads, and sets `io.opentelemetry.sdk.logs` (used only by this pipeline) to OFF. Shared exporter loggers keep their normal levels, so existing Trace export diagnostics such as `Failed to export spans ... 401` are unchanged whether business Logs are on or off. Custom logging configurations must retain the filter and the `sdk.logs` OFF rule. / 指标无高基数标签;适配器每分钟最多一次固定文字。原始 SDK 诊断可能回显凭证,ERROR 级别也不安全;默认配置只在业务 exporter 自己的网络线程上丢弃 OTel/OkHttp 诊断(含 JUL 桥接),并关闭仅本管道使用的 sdk.logs。共享 exporter 记录器级别不变,Trace 导出错误提示无论业务 Logs 开关均保持原样。自定义日志配置需保留该过滤器和 sdk.logs 规则。
|
||||
|
||||
Log filtering alone does not contain OTel 1.31's uncaught asynchronous callback failures (for example, an HTTP 401 with a malformed chunked body). The production business exporter is constructed in a private ThreadGroup: only its `okhttp-dispatch-*` threads' uncaught exceptions are discarded without rendering or forwarding the throwable. This increments `errors` and retains the same fixed warning; the existing completion deadline separately counts one failed batch and recovers permits. The JVM default handler is never changed. Other threads, including same-named threads outside that group and shared OkHttp housekeeping threads, delegate to their normal parent/default handler unchanged. Pipeline shutdown cancels its dispatcher executor; containment stays attached until its last callback exits, then no owned dispatcher remains. An inert group can outlive the pipeline for shared OkHttp housekeeping, but delegates those exceptions and only weakly references metrics. / 仅过滤日志不足以阻止 1.31 异步回调异常直写 stderr。业务 exporter 在私有线程组内构建,只隔离该组导出 dispatcher 的未捕获异常,计 errors 并保留固定告警;预算到期另计一批失败并恢复许可。不改 JVM 默认 handler,其它线程仍按原处理链执行。关闭时取消导出线程,隔离保留至回调退出;共享 housekeeping 可保留空闲边界组,但不受抑制且不强引用指标。
|
||||
|
||||
This safe-diagnostics boundary covers this dedicated business Logs pipeline with the shipped diagnostics filter defaults and the verified managed 1.31 sender. It is not process-wide exception redaction: unrelated Trace/agent exporters keep their own (unchanged) diagnostics, and application failures, custom logging/SDK overrides, agents intercepting exceptions, heap dumps and receiver-side logs are outside it. SDK upgrades must reverify that the sender reports results on dispatcher threads owned by the private group, plus the captured stdout/stderr and Trace-coexistence tests; no BOM upgrade is included here. / 安全诊断范围限于默认日志规则和已验证的 1.31 业务管道,不是整个 JVM 的异常脱敏;Trace 等其它 exporter 保留各自原有诊断,应用异常、配置覆盖、Agent、堆转储和接收端日志不在范围内。升级 SDK 必须重验回调线程归属、输出回归与 Trace 共存测试。
|
||||
|
||||
Troubleshoot using the protected Actuator counters above, this fixed warning, receiver-side status/health, and independent DNS/network/TLS/auth checks with secret-safe tools. If raw SDK diagnostics are indispensable, first remove production credentials or reproduce with synthetic credentials in an isolated environment, restrict log access/retention, and knowingly accept disclosure risk. Business exporter raw diagnostics are dropped by the filter regardless of logger level; reproduce with a deployment-owned logging config that omits `BusinessLogsDiagnosticsFilter` (and, for processor diagnostics, sets `io.opentelemetry.sdk.logs` to DEBUG), then restore the shipped config and restart; do not share unredacted SDK output. / 排障优先使用受保护指标、固定告警及接收端健康,不输出 Secret。确需 SDK 原始细节时,在隔离环境用假凭证复现,限制访问与保留并明确接受泄露风险;业务 exporter 的原始诊断不受日志级别影响,需用部署方自有日志配置临时去掉过滤器复现,结束后恢复默认配置并重启,勿分享未脱敏输出。
|
||||
|
||||
| Symptom / 现象 | Check / 排查 |
|
||||
| --- | --- |
|
||||
| Metrics 404 / 指标 404 | 显式启用 exposure 列表并重建镜像/容器;指标权限与导出开关独立。默认 404 是预期行为 |
|
||||
| Demo no records / 本地无记录 | 先运行 demo-up;主机模式重启 make dev-server;容器模式确认项目名、env 文件与共享网络,避免把容器 localhost 当接收器 |
|
||||
| Startup fails / 启动失败 | Enabled without endpoint, bad full URL, invalid bounds/allowlist/header encoding; do not paste config secrets / 检查目标、范围、编码,不分享 Secret |
|
||||
| No records, no exporter metrics / 无记录或指标 | Default off? Environment explicitly forwarded? Container recreated? No historical backfill / 开关、透传、重建、新事件 |
|
||||
| Enqueued but failed / 已入队但失败 | `/v1/logs`, HTTP/protobuf (not gRPC 4317), DNS/network/TLS/auth and receiver Logs pipeline / 路径、协议、网络及鉴权 |
|
||||
| Drops / 丢弃 | Outstanding queue + in-flight, receiver throughput; tune bounded budgets, not an unbounded queue / 检查接收吞吐与在途占用,不改无界 |
|
||||
| Missing one approval fact / 少一条批准事实 | Source vs target namespace allowlist; content vs metadata review; transaction rollback / 来源与目标空间过滤、内容与元数据审核区别、回滚 |
|
||||
| Duplicate counts / 重复 | UID dedup, receiver ack ambiguity, agent bridges; never bridge ordinary app logs into this scope / UID 去重、确认歧义、Agent 日志桥接 |
|
||||
| Exported count exceeds sink count / 指标大于入库量 | 2xx partial rejection blind spot, downstream filters/retention; inspect receiver / 部分拒收盲区及下游过滤 |
|
||||
|
||||
Trace modes remain independent: `none`/`external-agent` still forbid the TRACE endpoint, not the business Logs endpoint. External agents must not bridge/re-export this dedicated scope or attach a second business Logs processor; verify real deployment agent plugins for duplicates. No actual external-agent installation is implied by application configuration tests. / Trace 限制仅针对 Trace 目标;部署方须检查 Agent 插件是否重复导出业务记录。
|
||||
|
||||
For executed verification and explicit remaining limits, see [acceptance matrix](../openspec/changes/add-otlp-business-event-logs/acceptance.md).
|
||||
21
docs/examples/business-event-logs/collector.yaml
Normal file
21
docs/examples/business-event-logs/collector.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
processors:
|
||||
memory_limiter:
|
||||
check_interval: 1s
|
||||
limit_mib: 64
|
||||
spike_limit_mib: 16
|
||||
exporters:
|
||||
file:
|
||||
path: /data/business-logs.json
|
||||
append: true
|
||||
flush_interval: 100ms
|
||||
service:
|
||||
pipelines:
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter]
|
||||
exporters: [file]
|
||||
22
docs/examples/business-event-logs/compose.yaml
Normal file
22
docs/examples/business-event-logs/compose.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Local trial only. JSON in the container is not production storage.
|
||||
name: ${SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT:-skillhub-business-logs-demo}
|
||||
services:
|
||||
business-logs-collector:
|
||||
image: otel/opentelemetry-collector-contrib:0.111.0
|
||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||
user: "0:0"
|
||||
mem_limit: 128m
|
||||
cpus: 0.5
|
||||
ports:
|
||||
- "127.0.0.1:${SKILLHUB_BUSINESS_LOGS_DEMO_PORT:-4318}:4318"
|
||||
volumes:
|
||||
- ./collector.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||
- business_logs_demo_data:/data
|
||||
networks:
|
||||
- business-logs
|
||||
volumes:
|
||||
business_logs_demo_data:
|
||||
networks:
|
||||
business-logs:
|
||||
external: true
|
||||
name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs-demo}
|
||||
|
|
@ -18,15 +18,19 @@
|
|||
|
||||
通过 `SKILLHUB_TRACING_MODE` 选择一种模式,修改后重启应用:
|
||||
|
||||
- `none`:默认模式。无应用内 OTel SDK 和 OTLP 导出,只保留 `request.id`。
|
||||
- `none`:默认 Trace 模式。无应用内 Trace SDK 和 Trace OTLP 导出,只保留 `request.id`;独立业务 Logs 开关不受此限制。
|
||||
- `otel-sdk`:使用 Micrometer Tracing + OTel Bridge;配置
|
||||
`MANAGEMENT_OTLP_TRACING_ENDPOINT` 后才向 Collector 导出。
|
||||
- `external-agent`:应用内 Tracer 为 NOOP,由部署环境提供唯一的外部 Agent。
|
||||
SkillHub 只能校验自身配置,不能识别任意 JVM Agent;唯一 Agent 是部署检查项。
|
||||
|
||||
`none`/`external-agent` 不能配置 OTLP endpoint;`otel-sdk` 与外部 Tracing Agent
|
||||
`none`/`external-agent` 不能配置 TRACE OTLP endpoint;`otel-sdk` 与外部 Tracing Agent
|
||||
不得在同一进程中叠加。
|
||||
|
||||
业务分析使用独立、默认关闭的 LoggerProvider,不桥接应用日志或更改 Trace 采样。
|
||||
生产点只增加领域快照,提交后才入队;事件与字段必须通过统一 mapper 白名单。
|
||||
开发/部署契约见[业务事件 Logs](./business-event-logs.md),不要把审计或可靠通知改接为有损遥测。
|
||||
|
||||
## 3. 开发者接入方式
|
||||
|
||||
### 3.1 普通 HTTP 请求
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ Resolve validation errors before starting. Backend startup runs Flyway database
|
|||
|
||||
Suite review writes are enabled by default in the single-server release Compose topology; Suite Bundle confirmation is disabled by default. See [Bundle import](suite-bundle.md) for enabling it. For rolling upgrades where old and new backends coexist, follow the [Suite deployment order](https://github.com/iflytek/skillhub/blob/main/docs/25-skill-suites.md).
|
||||
|
||||
## Optional business-event OTLP Logs
|
||||
|
||||
Business analytics stays off by default and independent of tracing. Use `runtime.sh business-logs-enable`
|
||||
for an existing receiver, or `business-logs-up` for an optional Collector with an explicit downstream.
|
||||
For a local host Java process or container backend, use `make business-logs-demo-up`.
|
||||
For a runtime release deployment, pass `--ref <tag> --version <tag>` together: ref selects files; version selects only images.
|
||||
Metrics require the explicit `health,info,metrics,prometheus` exposure list and a server recreation.
|
||||
The [operations guide](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) covers both
|
||||
paths, Helm Secrets, admin queries, troubleshooting and cleanup. Download preparation is not completion
|
||||
or installation; this lossy stream is not an audit or billing system.
|
||||
|
||||
## CLI device authorization URL
|
||||
|
||||
`skillhub login --no-open --registry https://skills.example.com` first resolves `--token`, then `SKILLHUB_TOKEN`, then saved credentials for that registry. Device authorization starts only when no token is resolved; an invalid token does not automatically fall back. `--no-open` only disables automatic browser launch. Verify the resulting identity with `skillhub whoami --registry https://skills.example.com`.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ docker compose --env-file .env.release -f compose.release.yml logs --tail=100 se
|
|||
|
||||
Suite 审核写入在单机 release Compose 中默认开启;Suite Bundle 确认默认关闭。需要批量导入时,按 [Bundle 导入](suite-bundle.md#部署开关)启用对应开关。滚动升级新旧后端共存时,另按[套装部署顺序](https://github.com/iflytek/skillhub/blob/main/docs/25-skill-suites.md#部署顺序)操作。
|
||||
|
||||
## 可选的业务事件 OTLP Logs
|
||||
|
||||
业务分析默认关闭,与 Trace 独立。生产使用 `runtime.sh business-logs-enable` 接已有接收端,
|
||||
或用 `business-logs-up` 启动有明确下游的可选 Collector。本地使用 `make business-logs-demo-up`,
|
||||
自动适配主机 Java 与容器后端。runtime 发布部署同时传 `--ref <tag> --version <tag>`:前者选文件,后者只选镜像。
|
||||
指标需显式启用 `health,info,metrics,prometheus` 并重建 server。
|
||||
[运行手册](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) 提供生产接入、
|
||||
本地试用、Helm Secret、管理员查询、排障与停止步骤。下载准备不代表完成或安装成功。
|
||||
|
||||
## 升级、停止与排障
|
||||
|
||||
升级前保存当前镜像版本、配置,并备份数据库和技能对象存储。修改 `SKILLHUB_VERSION` 为目标发布版本,重新执行校验、`pull`、`up -d` 和业务验证。回退镜像不等于回退数据库;先检查目标迁移的兼容性。
|
||||
|
|
|
|||
171
openspec/changes/add-otlp-business-event-logs/acceptance.md
Normal file
171
openspec/changes/add-otlp-business-event-logs/acceptance.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Acceptance evidence — OTLP business-event Logs
|
||||
|
||||
Base: `9ebb99226dd1b3e1d4fd8dd3e36baae70a4ed273`; branch `feat/otlp-business-events-1009`. These are executed results, not a claim of a deployed browser/CLI production stack. Raw logs and receiver output are retained in the implementation worker's evidence directory; the worker report gives absolute paths. / 以下为实际运行结果,不等同完整生产部署验收;原始输出路径见 worker report。
|
||||
|
||||
## Event/producer matrix
|
||||
|
||||
`BusinessLogsProducerPostgresTest` uses actual Spring service proxies, real JPA repositories, Flyway migrations and PostgreSQL 16. Storage/scanner/avatar/notification/audit/compensation collaborators and package-validation/metadata-parsing collaborators are isolated mocks; business state and transaction boundaries are real. Each row has a committed scenario and a physical-transaction rollback scenario. Physical rollback emits zero records; `PROPAGATION_NESTED` savepoint rollback is excluded (it can export after outer commit). `REQUIRES_NEW` commit exports independently of outer rollback. / 服务代理、仓储、迁移和物理事务为真实依赖;物理事务回滚不导出,但保存点回滚明确不保证抑制;独立新事务提交不受外层回滚影响。外部协作组件用 mock 隔离,不宣称验证它们的端到端副作用。
|
||||
|
||||
| Producer path | Committed facts (exactly) | Rollback | Actor |
|
||||
| --- | --- | --- | --- |
|
||||
| Privileged auto-publication | publication effective | 0 | privileged submitting user |
|
||||
| PRIVATE confirmation | publication effective | 0 | confirmer |
|
||||
| Content review approval | publication effective + review approved | 0 | actual reviewer, not owner |
|
||||
| Content review rejection | review rejected | 0 | actual reviewer |
|
||||
| Initial promotion approval | target publication effective + source promotion approved | 0 | actual approver |
|
||||
| Promotion update approval | target publication effective + source promotion approved | 0 | actual approver |
|
||||
| Promotion rejection | source promotion rejected | 0 | actual reviewer |
|
||||
| Archive | status changed ACTIVE → ARCHIVED | 0 | owner |
|
||||
| Unarchive | status changed ARCHIVED → ACTIVE | 0 | owner |
|
||||
| Immediate narrowing | visibility changed to PRIVATE | 0 | requester |
|
||||
| Metadata-review expansion | visibility changed to PUBLIC, no new publication | 0 | expansion approver |
|
||||
| Published version yank | version yanked | 0 | admin actor |
|
||||
| Published download inside outer transaction | download prepared | 0 | anonymous: user type without user.id |
|
||||
|
||||
Result: 39 real-service/JPA scenarios passed in `final-focused-3.log`: the 26 commit/rollback scenarios above plus 13 denied-actor cases, one for every path, asserting no exported fact and unchanged persisted skill/version/review/promotion state. Includes PRIVATE publication and review-task rollback assertions, actor checks and sensitive-field exclusion. Dedicated nontransactional real-download-service test separately passed: one anonymous preparation; no facts for review package or denied anonymous/unauthorized PRIVATE access. / 上表 26 个提交/回滚场景及逐路径 13 个拒绝授权场景通过;拒绝时不发事件、持久状态不变。无事务真实下载服务另验证一次匿名准备,以及审核包、匿名/无权限私有请求不发事件。
|
||||
|
||||
## Cross-cutting acceptance
|
||||
|
||||
| Requirement | Executed assertion | Result / evidence |
|
||||
| --- | --- | --- |
|
||||
| TDD | New mapper/config tests compiled before implementation and failed on missing implementation symbols | RED `red-unit.log`; initial GREEN 12 tests in `green-unit-2.log` |
|
||||
| Mapper boundary | Exact fixed keys; no rejection reason/submitter/unknown event serialization; namespace/event allowlists; anonymous omission; system actor; length limits; reserved built-in publisher | PASS `BusinessEventMapperTest` |
|
||||
| Default off | No pipeline/listener/provider bean; no default receiver | PASS `BusinessLogsConfigurationTest` |
|
||||
| Invalid configuration | Enabled+empty URL rejected; metadata/userinfo/query/file URL and bad budgets/event names rejected without echoing supplied URL; URL-encoded headers and control-character rejection | PASS `BusinessLogsConfigurationTest` |
|
||||
| Capture before commit | Valid unsampled span survives scope end before PostgreSQL commit; Repair 1 additionally seeds distinct MDC/Baggage secrets and checks exported attributes/body/resource | PASS `BusinessLogsTransactionTest`; see Repair 1 evidence below |
|
||||
| Trace coexistence | Actual production configuration exports 9 records each with none, otel-sdk (sampling=0), external-agent mode | PASS, actual Collector output 27 records / 9 names / 27 unique UIDs |
|
||||
| Actual OTLP receive/query | Pinned Collector contrib 0.111.0 file exporter; documented jq query run against real received JSON | PASS `collector-received.jsonl`, `collector-query.tsv`, `collector-query-summary.json` |
|
||||
| Queue full, deterministic | 1000 caller events with blocked exporter; exactly 999 drops; bounded shutdown | PASS final focused run: 12 ms caller loop, 169 ms close for 100 ms wait budget (includes scheduling/cleanup overhead) |
|
||||
| Live receiver stalled | Pause real Collector; queue=2, batch=1, HTTP timeout=500ms; 1000 caller events; failures counted | PASS final focused run: 10 ms caller loop, 998 drops |
|
||||
| Live receiver down | Stop real Collector; publishing returns without exception; export failure counter increments | PASS focused run: caller 0 ms |
|
||||
| Duplicate semantics | HTTP receiver first responds 503 then 200; retry bytes identical and include the same UID; auth appears only in transport header | PASS `BusinessLogsHttpTest`, 2 requests / 1 SDK-exported record |
|
||||
| Partial rejection blind spot | Real protobuf HTTP 200 partial_success rejected=1; no whole-batch replay; SDK reports success | PASS expected SDK limitation, 1 request / 1 SDK-success counter |
|
||||
| Resource bounds | Java test fork capped at 512 MiB; Collector Docker inspect verifies 128 MiB and 0.5 CPU; at most 16 record attributes; string cap tested | PASS focused command with `-DargLine=-Xmx512m`; not a throughput/byte-accurate heap benchmark |
|
||||
| Existing consumers | All affected domain service tests, 20 notification tests and 13 Trace configuration/filter tests | PASS final focused run: 289 total tests, no failures/errors/skips |
|
||||
| Release config | Actual make target with a synthetic valid fixture (removed afterward); existing release shell regression suite | PASS `validate-release-config.log`, `release-config-tests.log`; no real credential file used |
|
||||
| Helm/Compose | Helm 3.17.3 lint/render, existing chart contracts; new Python deployment contracts assert default-off, endpoint requirement, Secret reference and env forwarding | PASS 1 chart, 4 Python tests without skips (`business-logs-config-with-helm-final.log`), `helm-contracts.log` |
|
||||
| Backend packaging | Reactor app+dependencies package with tests explicitly skipped (tests verified separately) | PASS `backend-package-final.log` |
|
||||
| Local doc/source checks | Changed Markdown relative targets and code-fence pairing; required OpenSpec headings | PASS 11 files, 19 relative targets, 5 requirements / 9 scenarios (`docs-source-check.json`); not a site build or OpenSpec CLI run |
|
||||
|
||||
The raw measurement values above are from the named focused run, not promised production latency percentiles. Shutdown has a bounded SDK wait budget plus local cleanup/scheduling overhead. / 延迟数字是该次测试实测,不是生产百分位承诺;退出等待预算之外有本地清理和调度开销。
|
||||
|
||||
## Repair 1 — independent review RETURN
|
||||
|
||||
Repair base: `894e5860ce19059a27dab8cf94642aba56d33a29`; changes remain uncommitted. Evidence filenames in this section are relative to `evidence/repair1/` in the worker report, not the earlier evidence directory. The original results above remain historical; the current focused result is **299 tests, 0 failures, 0 errors, 0 skips** (193 domain + 106 app; 73 business-Logs tests). The same full focused selector/environment was rerun, including the new `Business*Test` classes. / 本轮在独立评审退回后修复,未提交;本节路径属于 repair1 子目录。之前结果保留为历史,当前定向结果为 299 项全部通过,其中业务日志 73 项。
|
||||
|
||||
| Finding / boundary | Executed assertion and change | RED → GREEN evidence |
|
||||
| --- | --- | --- |
|
||||
| Abandoned SDK result / permit leak | Production exporter receives HTTP 400, declares 100 body bytes but sends one and closes; export-budget guard fails once, then two HTTP 200 exports succeed. Requests=3, enqueued=3, exported=2, failures=1, drops=0; no restart | RED `red-permit-http.log` (1 test, failure-counter await times out at zero); GREEN `green-repair-focused.log` and `final-focused-green.log`, `BusinessLogsHttpTest` |
|
||||
| Exactly-once cleanup | Deadline wins over an unfinished result; a second export occupies the recovered capacity; late SDK success OR failure cannot admit a third record or alter the failed outcome. Two parameter cases pass | RED `red-guard-diagnostics-boundaries.log` (2 guard timeouts); GREEN `final-focused-green.log`, `BusinessLogsPipelineTest` |
|
||||
| SDK credential disclosure | Real 401 protobuf Status echoes synthetic Authorization; real malformed HTTP status line produces a credential-bearing ProtocolException. Shipped Logback package OFF rules suppress raw SDK output; fixed warning + failure counter remain. Captured stdout/stderr checked in BOTH text and async JSON modes; no synthetic credential | RED `red-guard-diagnostics-boundaries.log` (2 log assertions fail with credential output); GREEN `final-focused-green.log`, 4 `BusinessLogsDiagnosticsTest` cases |
|
||||
| Savepoint contract | Explicitly exclude `PROPAGATION_NESTED` rollback suppression in operator guide/design/spec/acceptance. Real PostgreSQL: nested rollback + outer commit leaves 0 rows but exports 1 record; `REQUIRES_NEW` commit + outer rollback leaves only the independent row and exports only its record. A documentation consistency test covers all four contract files | RED `red-guard-diagnostics-boundaries.log` (documentation assertion fails; both runtime boundary tests already pass); GREEN `final-focused-green.log`, `BusinessLogsTransactionTest` |
|
||||
| Non-vacuous MDC/Baggage exclusion | Assert distinct sensitive keys/values are actually present at dispatch, retain unsampled span across scope end, then assert neither keys nor values in exported attributes, body or resource | RED `red-guard-diagnostics-boundaries.log` (fixture precondition fails on missing MDC sentinel, not a product disclosure); GREEN `final-focused-green.log`, `BusinessLogsTransactionTest` |
|
||||
| Deployment regression | New deployment contracts 4/4, no skips; existing Helm configuration and release validation regression suites both pass; no Helm/Compose setting changed | `deployment-business-logs.log`, `deployment-helm-contracts.log`, `deployment-release-contracts.log` |
|
||||
| Live receiver regression | Real pinned Collector receives 27 records / 9 names / 27 distinct UIDs again. Paused receiver: 1000 caller events in 11 ms, 998 drops; stopped receiver: caller 0 ms, one failure. Blocked exporter: 12 ms caller loop, 999 drops, close 216 ms for 100 ms wait budget plus overhead | `final-focused-green.log`, current `collector-received.jsonl`; recorded values are observations, not SLOs |
|
||||
|
||||
The first full rerun (`final-focused.log`) executed 299 tests but had 4 diagnostic-test setup errors: querying an uncreated Logback child returned null. The fixture now creates the child before checking its inherited OFF level; the subsequent complete rerun above is green. `green-repair-focused.log` is an earlier 15-test pass before extending diagnostics to both log formats; it is not the final count. No broad-backend/base comparison or docs-site build was rerun in Repair 1; the limitations below are unchanged. / 首次全定向检查中诊断测试的子 logger 尚未创建,4 项 setup 报错;修正测试 fixture 后完整重跑通过。不将中间失败或早期 15 项绿色记录冒充最终结果;本轮未重跑全量后端/基线对比或文档站构建。
|
||||
|
||||
## Repair 2 — second independent review RETURN
|
||||
|
||||
Repair base and unchanged HEAD: `74e2bc1457bcb1046036ed1aee185b92e9751ab7`, branch `feat/otlp-business-events-1009`; changes are uncommitted. This section supersedes Repair 1's incomplete diagnostic-containment claim, not its historical results. Evidence filenames below are relative to `evidence/repair2/` in the worker report. / 第二轮修复以此提交为基线且不提交;本节替代上一轮不完整的诊断隔离声明,历史运行结果仍保留。
|
||||
|
||||
Full Repair 1 focused command/environment rerun: **307 tests, 0 failures, 0 errors, 0 skips** (193 domain + 114 app; 81 business-Logs cases). Counts include nested JUnit classes and are parsed from this run, not stale Surefire output. Business cases: mapper 6, configuration 10, HTTP 3, transaction 8, pipeline guard 2, diagnostics 6, exporter boundary 2, producer/JPA 43, Collector 1. / 完整定向命令重跑 307 项全部通过,包含嵌套测试类,非旧报告计数。
|
||||
|
||||
| Finding / boundary | Executed assertion / implementation | RED → GREEN evidence |
|
||||
| --- | --- | --- |
|
||||
| Uncaught credential-bearing callback | Exact local raw HTTP 401 + Transfer-Encoding chunked + invalid first chunk `1 Bearer <synthetic placeholder>`. BEFORE fix both shipped text/JSON modes print raw IllegalStateException/ProtocolException to captured stderr, despite successful deadline recovery. AFTER fix neither stdout nor stderr (including real Logback console output) contains the placeholder or uncaught-exception text; fixed warning remains. Requests=2, enqueued=2, failures=1, callback errors=1, exported=1, queue-full=0; later event exports without restart | RED `red-chunked-diagnostics-runtime.log`: 2 tests / 2 assertion failures / 0 errors; GREEN `green-diagnostics-boundary-final.log` and `final-focused-green.log`, 6 diagnostic cases |
|
||||
| Narrow ownership/lifecycle | Private ThreadGroup contains only its `okhttp-dispatch-*` uncaught exceptions; actual production dispatcher group asserted, dispatcher threads terminate after context close. JVM default handler identity unchanged before/during/after. Same-named unrelated thread outside the group and non-dispatcher inside it both reach the previous default handler with original throwable; initialization failure is fixed text with no cause, interrupted caller's flag restored | PASS `BusinessLogsExporterBoundaryTest` (2 cases), plus text/JSON lifecycle assertions in diagnostics |
|
||||
| Actual managed API | Public 1.31 Logs builder has no executor/transport/thread-factory setter. Bytecode confirms per-exporter OkHttp dispatcher factory captures constructor thread's group. No BOM/dependency upgrade, global handler replacement, SDK-private import or reflection | `otel-1.31-builder-api.txt`, `otel-1.31-dispatcher-bytecode.txt`, `otel-1.31-thread-factory-bytecode.txt` |
|
||||
| Producer identity gap | Every committed matrix row asserts exact skill/version/namespace IDs (including absence where not defined), source, current visibility, status old/new and visibility old/new (including absence on other events), plus review/promotion IDs. Promotion uses persisted distinct target skill/version/namespace for publication and source for decision. All 26 commit/rollback and 13 denial rows remain green | PASS `green-producer-identities.log`, `final-focused-green.log`; no observed identity bug or invented identity RED |
|
||||
| Promotion filter gap | Both initial AND update approvals run with source-only and target-only namespace allowlists through real Spring producers and the production mapper/listener/pipeline; retain only source decision or target publication respectively; assert exact surviving record identity and no precommit record | PASS 4 additional PostgreSQL cases; producer total 43 |
|
||||
| Rollback claim scope | Narrowed code comment: all 13 rollback rows prove zero telemetry; independent persisted rollback assertions additionally cover PRIVATE confirmation and content review approval only, not every row's full mutation set. Denied-row state checks remain separate | Source/comment and assertions in `BusinessLogsProducerPostgresTest` |
|
||||
| Deployment regression | No external setting or logging XML changed; exporter wiring changed, so Python deployment contracts and existing Helm/release shell suites rerun with synthetic fixtures | PASS `deployment-business-logs.log`: 4/4, no skips; `deployment-helm-contracts.log`, `deployment-release-contracts.log`: suite exit 0 |
|
||||
| Real receiver regression | Pinned Collector receives/query confirms 27 records / 9 event names / 27 unique UIDs. Paused receiver: 1000 caller events in 8 ms, 998 drops; stopped receiver: caller 0 ms, failure counted. Blocked exporter: 12 ms loop, 999 drops, close 218 ms with 100 ms SDK wait budget plus local overhead | PASS `final-focused-green.log`, `collector-received.jsonl`, `collector-query.tsv`, `collector-query-summary.json`; observations, not SLOs |
|
||||
|
||||
An initial RED fixture run (`red-chunked-diagnostics.log`) failed 2 startup assertions because retry-initial-backoff exceeded the test's lowered maximum; corrected before the runtime RED above. The first boundary GREEN attempt (`green-diagnostics-boundary.log`) had 2 fixture failures: enumerating all private groups included shared OkHttp housekeeping retained from another context, rather than just live dispatcher groups. The final fixture filters dispatcher threads; all 8 diagnostic/boundary cases pass in `green-diagnostics-boundary-final.log`, then the full run passes. Neither intermediate failure is presented as evidence of a passing run. / 初次 RED 为测试退避参数不一致;修正后实际复现泄露。首次 GREEN 因统计了共享 housekeeping 的线程组而失败,改为检查实际 dispatcher 后 8 项通过,再完整重跑通过。
|
||||
|
||||
Scope: containment is private to this verified managed business sender, with shipped SDK logger OFF defaults. Non-dispatcher housekeeping delegates unchanged; its inert group may remain alive but weakly references metrics. No global handler is installed/restored; containment lasts through owned callback exit. Unrelated exporters/application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs remain outside the guarantee. SDK upgrades require revalidation. Broad backend/base comparisons, packaging and docs-site build were not rerun in Repair 2; previous limitations below remain unchanged. `git diff --check` passes; owned containers/networks/volumes are cleaned up. / 隔离非全 JVM 脱敏,版本与默认日志范围及剩余部署责任详见设计和运维文档;本轮未重跑全量后端、打包或文档站构建,不冒称消除既有环境限制。
|
||||
|
||||
## Repair 3 — maintainer review (PR #1029, `bd30ac1`)
|
||||
|
||||
Finding: the global `io.opentelemetry.exporter = OFF` rule also hid existing Trace exporter errors when business Logs were disabled (reviewer reproduced 2 Trace 401s with 0 error lines vs. 2 `Failed to export spans` on main's logback config). / 维护者复现:全局 OFF 在业务 Logs 关闭时也屏蔽了既有 Trace 导出错误。
|
||||
|
||||
Change: removed the global exporter OFF; added `BusinessLogsDiagnosticsFilter` (Logback TurboFilter) that drops `io.opentelemetry.*` / `okhttp3.*` events only on threads of the business exporter's private `BusinessLogsExporterBoundary` group; kept `io.opentelemetry.sdk.logs` OFF (business pipeline only). / 去掉全局 OFF,改为只在业务 exporter 私有线程组上过滤 SDK 诊断。
|
||||
|
||||
| Scenario | Assertion | RED → GREEN |
|
||||
| --- | --- | --- |
|
||||
| Logs disabled + Trace 401 | Real `OtlpHttpSpanExporter` → local receiver returns 401 on `/v1/traces`; captured output contains `Failed to export spans`, `401` and the receiver message, text and JSON formats | RED with `bd30ac1` logback (await timeout, 0 lines); GREEN |
|
||||
| Logs enabled + Trace 401 coexist | Business Logs 401 echoing synthetic credential and Trace 401 in one logging setup: Trace diagnostic present; credential and `Failed to export logs` absent; fixed business warning present | RED with `bd30ac1` logback (Trace line missing); GREEN |
|
||||
| Sensitivity of the filter | Same tests with the global OFF removed but no filter: existing credential tests fail (6 failures: credential / `Failed to export logs` printed) | Proves the filter, not the logger level, provides the business containment |
|
||||
|
||||
Focused command (Repair 1 set, JDK 21): **311 tests, 0 failures, 0 errors, 0 skips** (adds 4 Trace-coexistence cases). Broad suite, deployment contracts and real Collector run were not rerun in Repair 3; no deployment setting changed. / 定向 311 项全部通过;本轮未重跑全量与部署契约。
|
||||
|
||||
## Broad suite and environment limits
|
||||
|
||||
Broad `make test-backend` runs are NOT green. Initial candidate: 2732 tests, 2 failures, 1 error, 30 skips. Final candidate broad run: 2762 tests, 3 failures, 11 errors, 30 skips. Frozen base: 2712 tests, 2 failures, 2 errors, 30 skips. Failures are in GitHub integration tests; exact failing methods are retained in the count JSONs. Four methods occur in both the final candidate's and base's broad failure sets (Go executor, credential privacy, two budget cases), but the full failure sets and error modes vary. Do NOT infer that every candidate failure was proven pre-existing. Subsequent identical five-class GitHub focused commands PASS all 37 tests on BOTH the candidate and frozen base. An independent SQL/host clock probe found the Docker database clock ahead of the host in every sample, consistent with timing-sensitive eligibility checks, but it is NOT asserted as the proven cause of every failure. No clock/global configuration was changed. The authorized detached base worktree was removed. / 全量检查不是绿色;基线与 candidate 有四个相同失败方法,但集合及错误形式不同,不冒称全部故障已证实为基线问题。随后相同五类 GitHub 定向测试在两边均 37 项通过。独立探测发现 Docker 数据库时钟领先宿主机,与时间筛选类故障相符,但未证明所有故障根因;未改系统时钟/全局配置。基线临时工作树已移除。
|
||||
|
||||
The 13 added denial cases and two mapper edge assertions were finalized after the last broad run compiled its test classes; all final sources were then exercised by the successful 289-test focused run. / 最后一次全量编译测试类后补齐 13 项拒绝授权及两个 mapper 边界断言,最终源码由 289 项定向测试实际验证。
|
||||
|
||||
Docs site build was attempted. VitePress was absent; `npm ci` was blocked by the environment's network guard for both the lockfile registry and an explicit npmjs alternative. This is reported as BLOCKED, not passing. Markdown/source/link checks are separate from a built site. / 文档站构建受依赖下载网络限制,未冒称通过。
|
||||
|
||||
Not executed: real external Java-agent/plugin installation, TLS/mTLS production deployment, a browser/CLI deployment smoke, forced-process-crash recovery or sustained throughput/heap profiling. There is no outbox/replay guarantee to test. Agent duplicate suppression, authorized egress/DNS/redirect policy, production Secret provisioning, receiver persistence and retention remain deployment responsibilities. All application mode combinations were exercised without installing a Java agent. / 未执行真实 Agent 注入、生产 TLS/mTLS、完整 Web/CLI 部署或持续负载测试;相关限制和部署责任明确保留。
|
||||
|
||||
## Repair 4 — deployment integration and test isolation
|
||||
|
||||
The maintainer's deployed receive test is the starting point, not a substitute for this round's executions. Evidence filenames below refer to the separate worker evidence directory; credentials and host-specific paths are not committed.
|
||||
|
||||
### Process-global diagnostics test
|
||||
|
||||
The unmodified minimal selector was run twice with Surefire's default filesystem ordering: Trace ran first and both runs passed 18 tests. With Surefire's real `alphabetical` ordering, diagnostics ran first and both RED runs had **18 tests, 1 failure, 0 errors, 0 skips**. The failure is `SkillHubTracingConfigurationTest.otelSdkScopeShouldPublishTraceCorrelationToMdc`, with a null MDC trace ID. The diagnostics fixture changes global Boot Logback/JUL state and creates raw SDK spans before Boot installs the OTel ContextStorage MDC wrapper. Reloading Logback does not reset that process-global SDK initialization. The managed ContextStorage wrapper behavior was inspected from the actual dependency bytecode.
|
||||
|
||||
The fixture is now tagged and runs in a dedicated, non-reused Surefire fork. The ordinary execution excludes that tag; the isolated execution includes only the diagnostics class. All original assertions remain. This is isolation, not a claim of restoring every global singleton in-process.
|
||||
|
||||
| Executed set | Final Surefire XML totals | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Baseline minimal selector, alphabetical, twice | 18 each; 1 failure, 0 errors/skips | `red-3.log`, `red-4.log`, corresponding XML directories |
|
||||
| Isolated minimal selector, alphabetical, twice | 18 each; 0 failures/errors/skips | `green-1.log`, `green-2.log`, corresponding XML directories |
|
||||
| Exact Repair 3 selector (now also matches the new metrics security class) | 312; 0 failures/errors/skips | `focused.log`, `focused-xml/` |
|
||||
| Entire observability test package | 117; 0 failures/errors/skips | `observability.log`, `observability-xml/` |
|
||||
| Metrics opt-in/default-off security tests | 3; 0 failures/errors/skips | `metrics-java-final.log`, `metrics-xml/` |
|
||||
|
||||
### Real deployment paths versus template contracts
|
||||
|
||||
| Path | Real execution and assertion | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Branch server image | Built with `server/Dockerfile`, the same context/Dockerfile as image CI; inspected the resulting arm64 image. Later server edits were test-only | `docker-build.log`, `image-inspect.log` |
|
||||
| Default off | Real bootstrap-admin PRIVATE publication and authenticated download return 200; existing receiver has 0 requests; no optional Collector; admin metrics/prometheus return 404 | `real-runtime-green2.log`, `default-off-received.json` |
|
||||
| Existing receiver | Independent plain OTLP/HTTP receiver on the private network receives the new publication and download; 2 records queried | `real-runtime-green2.log`, `existing-received.json` |
|
||||
| Application config update | Change endpoint to a second receiver, rerun enable; old receiver stays at 2 records, new receiver receives 2; deployment header bytes unchanged | `real-runtime-green2.log`, `endpoint-update-received.json` |
|
||||
| Optional production Collector | No host bindings, 128 MiB / 0.5 CPU, explicit downstream; downstream count rises from 2 to 4 records | `real-runtime-green2.log`, `collector-inspect.log`, `optional-collector-received.json` |
|
||||
| Stop and start | Stop Collector and SkillHub, then start both; external network ID, deployment env bytes and existing records retained; download the pre-existing package successfully, downstream count rises from 4 to 5 | `real-runtime-green2.log`, `stop-start-received.json` |
|
||||
| Disable | Recreate with collection off; new publication/download return 200 and receiver count stays at 5 | `real-runtime-green2.log`, `disabled-received.json` |
|
||||
| Collector config update | Change downstream endpoint, rerun up; new target rises from 2 to 4 records while old target stays at 5; downstream header bytes unchanged. Disable again and prove no new exports | `downstream-update.log`, `downstream-update-received.json`, `final-disabled-received.json` |
|
||||
| Host process | Actual `make dev-server`, offline wrapper packaging, real PostgreSQL/Redis, scanner disabled; automatic loopback receive address; new publication/download and records/verify pass, including rerunning demo up | `host-dev-final.log`, `host-query-final.log` |
|
||||
| Containerized backend | Make targets recreate the branch backend on the shared private network; actual JSON contains publication/download for the new skill ID; stop/start verify retains prior records | `demo-container-final.log`, `demo-container-records.log` |
|
||||
| Metrics | Explicit exposure list; real super-admin session gets 200 for business counter and Prometheus, normal session 403, anonymous 401. Counter and scrape both show 2 enqueued/exported | `real-runtime-green2.log`, `metrics-counter.txt`, `metrics-prometheus.txt` |
|
||||
| Cleanup | Real clean retains both operator env files byte-for-byte at mode 600; owned project containers, named volumes, networks and test images removed; pre-existing dind images preserved; authorized dind and local relay stopped | `cleanup.log`, `cleanup-summary.json`, `local-port-cleanup.json` |
|
||||
|
||||
A real demo stop/start initially failed because the file exporter truncates its file by default. The fix uses `append: true` and a project-scoped local trial volume; up recreates the Collector so config-file changes apply without losing those trial records. This is not production storage. The real Collector Java test now restarts the container, waits for its HTTP pipeline and asserts all 27 original UIDs remain. Docker changed the dynamic host port on this host; an intermediate new test probe used stale cached mapping and failed. The final test inspects the actual restarted binding. Both the full focused run and observability package pass with this assertion (`collector-restart-final.log`, final suite logs).
|
||||
|
||||
Contract checks are separate: the fake-Docker lifecycle script covers default off, existing receiver, optional Collector, repeated up, updates, stop/start, disable, permissions, secret retention, flat OSS paths, and both Make modes. Python deployment checks pass 6 cases, including rendered private-network/volume/metrics settings. Helm configuration contracts and strict lint pass; the supplied minimal Helm image command required adding Python 3 for the chart's pre-existing Python contract checks. Bash 3's pre-existing empty-array issue in the storage selector test was avoided by running that test in a Linux Bash container. No production data or credentials were used.
|
||||
|
||||
`npm ci`, the VitePress build, weekly site generation/validation and `git diff --check` pass. No full backend rerun, browser/CLI E2E, production TLS/mTLS, live Helm install/upgrade, real external Java agent or durable downstream storage validation is claimed. Host dev verification intentionally used only PostgreSQL/Redis and PRIVATE admin publications rather than the full scanner/frontend stack. Versioned/flat retrieval was exercised through the same download function with a local file root because these new files are not published yet; OSS/release upload and PR publication remain with the owner. No push or PR mutation was performed.
|
||||
|
||||
## Repair 4a (independent review)
|
||||
|
||||
Starting tree: `c327ea2e`. Evidence filenames below are relative to the worker's `evidence-r4/repair-a/` directory. Each new regression was run RED before its implementation/documentation fix, then GREEN. The fake-Docker suite is registered in script CI; the Helm access case is a documentation contract, not a live Kubernetes test.
|
||||
|
||||
| Finding | Fix and executed regression | RED → GREEN evidence |
|
||||
| --- | --- | --- |
|
||||
| B1: image version changed file ref | Remove the global fallback; restore the entire lifecycle-argument function exactly to `2a64d152`. An existing analytics overlay plus `ps --version v0.2.25` records raw URLs under `main`, or `x` with `SKILLHUB_REF=x`. An env ref equal to the image version is retained in generated lifecycle commands. Guide and zh/en site explicitly pin `--ref <tag> --version <tag>` together | `b1-red.log`: both refs incorrectly use `v0.2.25`, lifecycle omits ref, exit 1. `b1-green.log`: correct main/x URLs and lifecycle ref, exit 0 |
|
||||
| B2: disable discarded attachment intent | Disable changes collection only, retaining the private-network flag. Private-receiver and optional-Collector enable → disable → enable both recreate server with the network overlay and retain the saved endpoint/network. Public HTTPS works without a private network | `b2-red.log`: private and Collector attachment failures, exit 1. `b2-green.log`: all three cases pass, exit 0 |
|
||||
| N1: first Collector network option ignored | Seed the independent env from `--logs-network` only at first creation. A later conflicting CLI network leaves the existing env byte-for-byte unchanged and uses its saved network | `n1-red.log`: default network instead of supplied name, exit 1. `n1-green.log`: supplied network plus unchanged existing env, exit 0 |
|
||||
| N2: Helm metrics access | Document server-Service port-forward, then admin login and metrics queries on that forwarded origin; the shared query preserves the selected BASE. No ingress changes | `n2-red.log`: missing Service port-forward, exit 1. `n2-green.log`: same-origin documentation contract passes, exit 0 |
|
||||
|
||||
Real Docker used the authorized isolated `release-test` context and a fresh `server/Dockerfile` image build. Actual admin PRIVATE publication/download requests returned 200 in enabled, disabled and re-enabled states. The plain private receiver had 2 records after enable, still 2 after disable, and 4 after enable without repeating network/endpoint flags. Inspect confirmed that the disabled server remained attached with collection false; both disabled business facts were absent and the receiver request count did not change. The optional Collector path was also run: first-creation CLI network seeded, existing env preserved, downstream record counts 6 → 6 → 8 across enable → disable → enable. Each enabled assertion matched publication/download for that newly created skill, not an old record. Evidence: `real-lifecycle.log`, the scenario `*-received.json` files, `docker-build.log`, `image-inspect.log`.
|
||||
|
||||
Real `ps --version trial` without `--ref` succeeded against the `file://` source with an existing analytics overlay, both with the default ref and `SKILLHUB_REF=x`. The URL-recording fake-fetch regression separately proves ref selection without a raw-base override. New remote tag/OSS availability is still not claimed.
|
||||
|
||||
All four `scripts/tests/runtime-*.sh` suites passed. The requested Python deployment command passed 6 cases without skips. Helm contracts (with `apk add -q bash python3`) and strict lint passed, with 1 chart and 0 failures. Release validation and workflow-security suites, VitePress build, weekly site generation/validation, shell syntax, exact-function comparison, privacy scan and range diff checks passed. Logs: `runtime-*-final.log`, `python-contracts.log`, `helm-contracts.log`, `validate-release-config.log`, `workflow-security.log`, `docs-build.log`, `docs-weekly.log`, `source-audit.log`.
|
||||
|
||||
Cleanup read-back confirmed owned containers removed, the isolated daemon's image/volume/network sets equal to their pre-run baselines, the local relay closed, and `release-test-dind` exited (`cleanup.log`, `cleanup-summary.json`). Shared builder cache was not pruned. No backend source changed, so Maven test suites were not rerun; the Dockerfile's normal packaging build skipped tests. No live Kubernetes/port-forward execution, new browser/CLI E2E, production TLS/authenticated storage, public release upload, push or PR mutation is claimed. Earlier broad-suite limits remain historical and unchanged.
|
||||
53
openspec/changes/add-otlp-business-event-logs/design.md
Normal file
53
openspec/changes/add-otlp-business-event-logs/design.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
## Architecture
|
||||
|
||||
Domain records retain their original accessors and compatibility constructors, and gain an immutable `BusinessEventSnapshot` (namespace ID, visibility, opaque actor/type, fixed source, Instant). Every production publisher of the nine included event types supplies this snapshot. No domain OTel dependency is introduced. Notification/search listeners keep their existing semantics. Missing legacy snapshots are ignored, not enriched asynchronously. The promotion rejection path adds one scalar-source lookup because its old event/request lacks source namespace/visibility; it does not export reasons or submitters.
|
||||
|
||||
The conditional app configuration owns a dedicated `SdkLoggerProvider`, BatchLogRecordProcessor and OTLP/HTTP Logs exporter. It exposes only the pipeline/listener as beans, not a global/provider bean that Boot might consume for Trace autoconfiguration. Resource is explicitly constructed from existing service settings, not detected. No application logs are bridged; no trace sampling/configuration is altered.
|
||||
|
||||
## Commit and context boundary
|
||||
|
||||
A synchronous Spring `@EventListener` maps fixed scalar data and captures only valid OTel SpanContext when the producer dispatches. It registers `TransactionSynchronization.afterCommit` for actual transactions, otherwise admits immediately. This is equivalent to AFTER_COMMIT + fallbackExecution but preserves the producer's span even if the span ends before commit. Each boundary catches telemetry RuntimeExceptions and counts them with bounded fixed-text diagnostics. No network IO, enrichment queries or space-waiting occurs in the listener. Disabled configuration creates no listener, provider, worker, exporter or OTel context objects.
|
||||
|
||||
The commit/rollback contract is physical-transaction scoped, not savepoint aware. `PROPAGATION_NESTED` savepoint rollback is explicitly excluded: an event dispatched inside the rolled-back savepoint still exports if the outer physical transaction commits. No current included producer configures NESTED. Producers needing savepoint-level suppression must not dispatch within nested/savepoint work. `REQUIRES_NEW` commits independently and exports even when the suspended outer transaction rolls back, suppressing only the outer event. PostgreSQL boundary tests assert both database rows and exact exported records; this is an honest exclusion, not new savepoint support.
|
||||
|
||||
Occurrence is `Instant.now()` immediately before producer publication, not the independently clocked persisted publishedAt or commit time. Sources identify domain branch, not caller-controlled Web/CLI labels. Forced internal auto-publication is a system actor; privileged human auto-publication remains a human actor. Anonymous downloads have actor type user and no user.id (user means user-request origin, not proof of an identified human). Review and promotion use the actual approver, not owner/uploader. Promotion decisions use source skill/namespace; effective publication uses the derived target.
|
||||
|
||||
## Bounds and observability
|
||||
|
||||
Standard SDK batching is retained. A nonwaiting semaphore around processor admission conservatively bounds queued PLUS in-flight records to `max-queue-size`; SDK queue capacity is at least that large, so accepted records cannot hit its hidden overflow boundary. A measured exporter owns a first-completion-wins result: SDK completion or the existing `(timeout + retry-max-backoff) * retry-max-attempts + 1s` deadline releases permits and updates counters exactly once. This handles OTel 1.31 async response-body exceptions that abandon their result; deadline/close completion counts one failed batch and late SDK completion is ignored. One daemon scheduler removes canceled deadlines; close stops it and fails outstanding guards. It bounds local accounting, not remote delivery/cancellation. This supplies exact admission/full metrics without reflection or a custom network queue/retry engine. SDK MeterProvider is explicitly noop; exporter health uses the existing Micrometer registry without global registration.
|
||||
|
||||
OTel exporter diagnostics are bridged from JUL by Boot and may disclose arbitrary receiver Status messages or transport exception text echoing headers; ERROR-only is insufficient. The shipped Logback configuration installs `BusinessLogsDiagnosticsFilter`, a TurboFilter that denies `io.opentelemetry.*` / `okhttp3.*` events only when the current thread belongs to the business exporter's private `BusinessLogsExporterBoundary` group (the managed 1.31 sender reports results on those dispatcher threads), and sets `io.opentelemetry.sdk.logs`, used only by this pipeline, OFF. Shared exporter loggers keep their configured level, so Trace (`otel-sdk`) export failures such as HTTP 401 keep their original `Failed to export spans` diagnostics whether business Logs are disabled or enabled. SkillHub's fixed warning (at most once/minute/pipeline) and Micrometer counters remain the business Logs operator signal. Custom logging must retain the filter and the `sdk.logs` rule; the operator guide documents the isolated troubleshooting path.
|
||||
|
||||
### Scoped asynchronous exception boundary (Repair 2)
|
||||
|
||||
The 1.31 `HttpExporter` can throw from OkHttp's callback while parsing a malformed non-2xx response body; log filtering and the accounting deadline do not stop the JVM uncaught handler printing that throwable to stderr. `BusinessLogsExporterBoundary` constructs only this exporter on a short-lived initializer in a private ThreadGroup. Its uncaught handler suppresses only threads whose group is that exact object AND whose restored factory name starts `okhttp-dispatch-`. It never inspects/formats/forwards the throwable, increments the existing adapter error counter and emits only the fixed warning; the completion guard independently records one failed batch and releases permits at its deadline. Initializer failures pass through a FutureTask and become a fixed cause-free startup exception; an interrupted caller finishes ownership transfer and restores its interrupt flag. Failure while constructing the pipeline cancels the built exporter and throws fixed text without the raw cause.
|
||||
|
||||
Why this rather than a global handler or new HTTP stack: `javap` against the actual managed 1.31 artifacts confirms the public `OtlpHttpLogRecordExporterBuilder` has NO transport/executor/thread-factory setter. `OkHttpUtil.newDispatcher()` constructs a per-exporter executor with `DaemonThreadFactory("okhttp-dispatch")`; its `Executors.defaultThreadFactory()` captures the constructing thread's group. This JDK ThreadGroup mechanism needs no SDK-private imports/reflection, dependency or BOM change and retains standard HTTP/retry behavior. The narrow version-specific assumption is dispatcher construction/group capture and restored naming, protected by the real receiver regression. Future SDK upgrades must rerun it and revisit the boundary, not assume compatibility.
|
||||
|
||||
There is no process-wide installation or restoration: the default handler is never set by production code, no thread scans mutate handlers, and multiple pipelines own independent groups. The scope is attached at exporter construction, before any export, and retired with that exporter's dispatcher executor at shutdown. It MUST remain attached to late callbacks even after close returns rather than exposing a teardown race. Group members other than dispatchers delegate through `super.uncaughtException` unchanged. OkHttp may first initialize shared housekeeping in this group; such threads are NOT suppressed and can keep the otherwise inert group alive. Its metrics reference is weak so this cannot retain the closed pipeline/registry. Java 21 reclaims groups when unreachable; no deprecated `ThreadGroup.destroy()` or global cleanup is needed. Tests assert actual production dispatchers use the private group, vanish after close, and never change the previous default handler. Guard tests additionally throw on an unrelated same-named thread outside the group and a non-dispatcher inside it: both reach the previous default handler with the original throwable.
|
||||
|
||||
Together with the shipped diagnostics filter and synchronous cause-free catches, this prevents raw business-exporter failure text in application stdout/stderr/logs, including malformed chunked 401 responses. It is deliberately NOT process-wide redaction: unrelated Trace/agent exporters, application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs are outside the guarantee. No previously installed handler is silenced for those threads. Trace exporter diagnostics are not suppressed; the new exception boundary adds no global handler side effect.
|
||||
|
||||
A single mapper allows only known event classes, fixed sources/actor types, scalar IDs, enums and bounded user ID. The SDK also limits 16 attributes and 256 code units. Three Resource strings are separately bounded. Configuration has hard count/duration/header/list bounds. Admitted pipeline memory is bounded by record count and fixed record shape, not a promised byte-accurate or zero-allocation budget. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event outside the SDK admission window; callers must not wrap unbounded bulk work in one transaction. This is not a process-wide heap cap. Saturation latency and receiver container limits are measured in tests; this is not a production throughput benchmark.
|
||||
|
||||
## SDK compatibility verified against 1.31.0
|
||||
|
||||
Boot remains 3.2.3, OTel remains BOM-managed 1.31.0. Only an explicit managed sdk-logs dependency is added. EventName API is absent: use custom `skillhub.event.name`. No global semconv schema is advertised; scope contract version is 1 and experimental log.record.uid semantics are documented locally.
|
||||
|
||||
The public HTTP Logs exporter builder supports RetryPolicy; set explicit finite attempts/backoff, or leave retry disabled for attempts=1. No retry code is implemented here. Logs endpoint is explicit and used as-is. Builders do not read OTEL_* automatically. The SDK treats 2xx as success without inspecting partial-success counts; counters reflect that limitation and tests send a real protobuf partial-success response. Retry tests verify identical payload/UID and transport-only headers.
|
||||
|
||||
The managed OkHttp sender uses its default redirect policy; the public 1.31 Logs builder has no redirect-policy customization. Do not fork the sender, use reflection or introduce an unrelated HTTP dependency: document nonredirecting receivers and enforce authorized destinations/TLS with deployment egress policy. This is an explicit limit relative to the draft RFC's redirect-validation aspiration, not a claim of full in-process SSRF prevention. Its retry interceptor bounds jittered backoff and does not honor Retry-After; SDK timeout/attempt limits may terminate before the configured maximum attempts.
|
||||
|
||||
## Data and destination policy
|
||||
|
||||
Private resource IDs are included by default only after instance operators opt in; empty namespace allowlist means all namespaces. No names/slugs/content are exported, avoiding entity serialization/enrichment and keeping governance facts available. IDs are linkable data, not anonymized. Namespace and exact event allowlists are startup-only. Credentials are deployment headers/Secrets only; endpoint userinfo/query/fragment and known metadata literal destinations are rejected without echoing input. Localhost/private receivers are allowed. Operators still own egress/DNS policy, TLS/truststore, retention and receiver access. Literal URL validation cannot prevent all metadata aliases/rebinding.
|
||||
|
||||
## Reliability and RFC deviations
|
||||
|
||||
Best-effort analytics only; commit-to-admission gaps, crashes, overflow, timeout, shutdown and receiver failures can lose data. No outbox/replay/exactly-once. Same SDK retry reuses mapped UID; a new domain dispatch gets a new UID. Approval and publication are intentionally distinct facts.
|
||||
|
||||
No runtime hot-reload. On restart to disable, the old process gets a bounded graceful drain opportunity rather than the RFC draft's immediate unsent cancellation. New admission stops on close; provider shutdown is joined for shutdown-timeout and the exporter is canceled at deadline. Accepted receiver data cannot be withdrawn. An active external-agent JVM/plugin installation, TLS/mTLS deployment and browser/CLI end-to-end smoke are separate deployment verifications, not inferred from application mode tests.
|
||||
|
||||
## Scope
|
||||
|
||||
All nine existing facts are included. Metadata/avatar review decisions with no existing decision event, hide/unhide, promotion revocation, submission/withdrawal, Suite events, search/view, transfer completion, installation, durable delivery and historical backfill are deferred. Existing metadata-review effective visibility events ARE included. The event catalog and full operator configuration live in docs/business-event-logs.md; executed results and limitations live in acceptance.md and the worker report.
|
||||
23
openspec/changes/add-otlp-business-event-logs/proposal.md
Normal file
23
openspec/changes/add-otlp-business-event-logs/proposal.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
## Why
|
||||
|
||||
Self-hosted deployments need vendor-neutral business analytics using the official image and deployment configuration, without a fork or a bespoke webhook protocol. Issue #1009 and maintainer comment 6463066865 authorize existing domain events → dedicated OpenTelemetry Logs → OTLP → operator receiver.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a default-off, independent OTLP/HTTP protobuf business Logs adapter in app.
|
||||
- Snapshot occurrence time, opaque actor, namespace ID and visibility at existing domain producers; keep notification/search semantics and permissions unchanged.
|
||||
- Include publication (all four producers), published-download preparation, content-review decisions, promotion decisions, archive/unarchive, effective visibility change and version yank.
|
||||
- Bound admission, attributes, retry, timeout and shutdown; expose Micrometer analytics-export health counters.
|
||||
- Document event semantics, deployment, data boundary, real receiver/query example and acceptance evidence alongside implementation.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `business-event-logs`: operator-controlled, lossy business analytics using OTLP Logs.
|
||||
|
||||
### Modified Capabilities
|
||||
None. No API, package, CLI, storage schema, authorization or audit contract changes.
|
||||
|
||||
## Scope and deferrals
|
||||
|
||||
Not audit, billing, notification, outbox, replay, historical backfill or exactly-once delivery. No application-log bridge, new trace pipeline, vendor SDK or BOM upgrade. Metadata/avatar review decisions without existing decision events, hide/unhide, promotion revocation, review submission/withdrawal, Suite events, search/views, stream completion and installation are deferred. Effective metadata-review visibility changes ARE included through their existing visibility event. No rejection comments, content, names/slugs, network identifiers or request headers are exported.
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Business Logs SHALL be opt-in and independent of Trace
|
||||
|
||||
The application SHALL create a dedicated LoggerProvider only when `skillhub.observability.business-logs.enabled=true`. It SHALL NOT register global OpenTelemetry, bridge application logs, change trace sampling or start a trace exporter. Empty endpoint while enabled SHALL fail startup without disclosing configuration values.
|
||||
|
||||
#### Scenario: Default deployment
|
||||
- **WHEN** no business Logs settings are provided
|
||||
- **THEN** no business LoggerProvider, processor, exporter, worker or listener is created
|
||||
|
||||
#### Scenario: Trace independence
|
||||
- **WHEN** business Logs is enabled with tracing `none`, `otel-sdk` or `external-agent`
|
||||
- **THEN** business records are accepted independently of trace sampling
|
||||
- **AND** only valid current span context is associated; request ID/MDC is never converted into trace identity
|
||||
|
||||
### Requirement: Existing business facts SHALL retain producer meaning
|
||||
|
||||
The event catalog in `docs/business-event-logs.md` SHALL define all included events, producer paths, occurrence boundary, actor and fields. Publication SHALL cover auto-publication, PRIVATE confirmation, review approval and promotion approval. Download preparation SHALL NOT mean transfer, installation or execution success.
|
||||
|
||||
#### Scenario: Commit and rollback
|
||||
- **WHEN** a supported event is published within a physical transaction (excluding savepoint-level rollback semantics)
|
||||
- **THEN** the adapter captures a scalar snapshot and trace context before handoff
|
||||
- **AND** export admission occurs only after that physical transaction commits; its rollback emits no record
|
||||
|
||||
#### Scenario: Savepoint exclusion and independent transaction
|
||||
- **WHEN** `PROPAGATION_NESTED` work dispatches an event and rolls back its savepoint, but the outer physical transaction commits
|
||||
- **THEN** savepoint-level rollback suppression is explicitly outside the contract; the event can still export despite the nested write rolling back
|
||||
- **AND** producers requiring savepoint suppression SHALL NOT dispatch within nested/savepoint work
|
||||
- **WHEN** `REQUIRES_NEW` work commits and the suspended outer transaction later rolls back
|
||||
- **THEN** the independently committed event is admitted and the outer transaction's event is suppressed
|
||||
|
||||
#### Scenario: Nontransactional download
|
||||
- **WHEN** an authorized PUBLISHED download successfully builds its result without an outer transaction
|
||||
- **THEN** one download-prepared record is admitted
|
||||
- **AND** a rejected request, preview/review package, Suite plan or metadata poll emits no such record
|
||||
|
||||
#### Scenario: Promotion identities and namespace filters
|
||||
- **WHEN** an initial or update promotion approval commits
|
||||
- **THEN** publication SHALL identify the derived target skill/version/namespace, and the promotion decision SHALL identify the source skill/namespace with no invented version field
|
||||
- **AND** a source-only namespace allowlist SHALL retain only the decision; a target-only allowlist SHALL retain only publication
|
||||
|
||||
### Requirement: Export SHALL have bounded, nonblocking business-thread cost
|
||||
|
||||
The adapter SHALL use asynchronous SDK batching and standard bounded exporter retry, never caller-runs network IO. Record count, batch size, attribute count/length, timeout, retry attempts/backoff and shutdown wait SHALL be bounded. Telemetry exceptions SHALL NOT propagate into business calls; failures SHALL be counted with bounded diagnostic logging.
|
||||
|
||||
#### Scenario: Receiver outage and saturation
|
||||
- **WHEN** the receiver stalls or fails and capacity is exhausted
|
||||
- **THEN** business calls do not wait for receiver IO or queue space
|
||||
- **AND** admission drops and failed exports are observable through existing Actuator metrics
|
||||
|
||||
#### Scenario: Ambiguous acknowledgement
|
||||
- **WHEN** an accepted batch is retried after an ambiguous network failure
|
||||
- **THEN** the same `log.record.uid` survives retries
|
||||
- **AND** receivers may deduplicate, without an exactly-once promise
|
||||
|
||||
#### Scenario: Abandoned export result
|
||||
- **WHEN** an SDK export result never completes, including a truncated non-2xx response-body callback failure
|
||||
- **THEN** the export-budget deadline SHALL fail the owned completion guard, release admission permits and count one failed batch
|
||||
- **AND** result completion, deadline and close SHALL compete for exactly one cleanup; late results SHALL NOT release again or change counters
|
||||
- **AND** later events SHALL be admitted and exported when the receiver recovers
|
||||
|
||||
### Requirement: Data export SHALL use a single explicit allowlist
|
||||
|
||||
A single mapper SHALL export only fixed documented scalar fields, including IDs of private resources. Namespace IDs and event names SHALL be operator-filterable. Human actors SHALL use only local opaque `user.id`; anonymous/unknown and system actors SHALL omit it. No entity serialization, enrichment queries, resource autodetection, content, credentials, URLs, IP/UA, request IDs or raw headers is permitted.
|
||||
|
||||
#### Scenario: Sensitive rejection fields
|
||||
- **WHEN** a domain rejection carries arbitrary reason or submitter information
|
||||
- **THEN** neither field is copied into business attributes or body
|
||||
|
||||
#### Scenario: Private resource scope
|
||||
- **WHEN** a private resource is within the configured namespace/event allowlists
|
||||
- **THEN** only IDs, visibility and documented operational fields are exported
|
||||
- **AND** names, slugs and content remain excluded
|
||||
|
||||
#### Scenario: Context and diagnostic credential boundaries
|
||||
- **WHEN** MDC and OTel Baggage contain distinct sensitive sentinels
|
||||
- **THEN** neither keys nor values SHALL appear in exported attributes, body or resource
|
||||
- **WHEN** receiver Status messages or transport exceptions echo transport credentials
|
||||
- **THEN** shipped logging SHALL suppress raw OTel/OkHttp diagnostics emitted on the business exporter's own threads and `io.opentelemetry.sdk.logs` diagnostics by default
|
||||
- **AND** existing Trace exporter diagnostics SHALL remain unchanged whether business Logs are disabled or enabled
|
||||
- **AND** fixed-text rate-bounded SkillHub diagnostics and counters SHALL remain available
|
||||
- **AND** operator documentation SHALL warn that explicitly raising SDK logging opts into possible credential disclosure
|
||||
|
||||
#### Scenario: Uncaught asynchronous exporter failure
|
||||
- **WHEN** the dedicated managed business exporter throws from an asynchronous response callback, including HTTP 401 with a malformed chunk-size line containing synthetic credential text
|
||||
- **THEN** its private dispatcher exception boundary SHALL prevent raw throwable text from reaching stdout, stderr or application logs with the shipped logging defaults
|
||||
- **AND** the adapter error counter and fixed warning SHALL remain available; the completion deadline SHALL count one failed batch and recover capacity for a later successful event
|
||||
- **AND** the boundary SHALL exist before export and remain on late callbacks through dispatcher shutdown, without changing the JVM default uncaught handler
|
||||
- **AND** unrelated threads (even same-named threads outside the private group) and non-dispatcher group members SHALL delegate unchanged to their previous parent/default handling
|
||||
- **AND** documentation SHALL scope this guarantee to the verified managed business sender and logging defaults, excluding unrelated exporters/application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs
|
||||
|
||||
### Requirement: Deployment and delivery limitations SHALL be explicit
|
||||
|
||||
User documentation SHALL include every configuration key/default/bound, direct and Collector deployment, Secret injection, loss/duplicate/shutdown semantics, SDK compatibility limits, troubleshooting, a reproducible real receiver/query example and an acceptance matrix separating executed from unverified scenarios.
|
||||
|
||||
#### Scenario: Graceful exit
|
||||
- **WHEN** the process shuts down
|
||||
- **THEN** new admission stops and queued records get only the configured bounded drain opportunity
|
||||
- **AND** remaining records may be lost; already accepted receiver data cannot be recalled
|
||||
71
openspec/changes/add-otlp-business-event-logs/tasks.md
Normal file
71
openspec/changes/add-otlp-business-event-logs/tasks.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
## Implementation
|
||||
|
||||
- [x] Inventory every existing producer/consumer for the nine included event types; preserve notification/audit/permission behavior.
|
||||
- [x] Add scalar producer snapshots (time, namespace ID, visibility, actor/type, fixed source), without domain telemetry dependencies.
|
||||
- [x] Add a dedicated default-off Logs pipeline; no global registration, application-log bridge or second Trace pipeline.
|
||||
- [x] Capture valid span identity before handoff; admit only after commit with nontransactional fallback.
|
||||
- [x] Central allowlist mapper, private-ID policy, stable per-record UUID, fixed attribute/string limits.
|
||||
- [x] Standard SDK batching/retry, bounded outstanding admission, timeout and shutdown; fixed-cardinality Micrometer counters and rate-bounded safe diagnostics.
|
||||
- [x] Explicit application/release environment mappings, release validator, Helm defaults and existing-Secret reference; no new vendor/third-party dependency or BOM upgrade.
|
||||
- [x] Bilingual operator documentation, deployment links, event catalog, configuration table, direct/Collector and Secret guidance.
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Write initial mapper/configuration tests before implementation; retain RED compiler output and subsequent GREEN output.
|
||||
- [x] Execute mapper/filter/anonymous/system/UID/config validation tests.
|
||||
- [x] Execute PostgreSQL transaction listener commit/rollback tests for all nine catalog facts, nontransactional real download/permission tests and unsampled context capture.
|
||||
- [x] Execute SDK HTTP retry/identical-payload UID/auth-only tests and partial-success blind-spot test.
|
||||
- [x] Execute real Collector receive-and-query with all nine events and three Trace configurations.
|
||||
- [x] Execute real-service/JPA commit/rollback and denied-actor matrix for all producer paths (39 scenarios).
|
||||
- [x] Execute paused/stopped live Collector saturation/outage and record measured latency/resource limits.
|
||||
- [x] Execute synthetic release validation, existing release regression checks, Helm lint/render/contracts and new deployment configuration tests.
|
||||
- [x] Finish broad backend/base runs and report reproduced versus unreproduced failures explicitly (full suite remains FAIL; identical GitHub focused checks pass on both).
|
||||
- [x] Run final focused checks after the last code edits, query actual receiver evidence with documented jq command, validate local docs/links and diff (site build BLOCKED by dependency-network guard).
|
||||
- [x] Verify cleanup of every owned temporary container/network/volume and the authorized baseline worktree; leave changes uncommitted.
|
||||
|
||||
See acceptance.md for final executed results and explicit unverified deployment scenarios; a task checkbox is not itself acceptance evidence.
|
||||
|
||||
## Repair 1
|
||||
|
||||
- [x] Reproduce truncated HTTP error-body permit leak before production changes; add bounded first-completion-wins export accounting and late success/failure regression cases.
|
||||
- [x] Reproduce synthetic credential disclosure in receiver Status and transport-error diagnostics; disable raw exporter/Logs SDK loggers by default and verify both shipped log formats retain safe operator signals.
|
||||
- [x] Explicitly exclude NESTED savepoint rollback from suppression; verify PostgreSQL NESTED rollback/outer commit and REQUIRES_NEW commit/outer rollback plus documentation consistency.
|
||||
- [x] Make MDC/Baggage exclusion non-vacuous with distinct seeded sentinels, dispatch preconditions and exported attributes/body/resource checks.
|
||||
- [x] Retain RED/GREEN evidence and rerun full focused command: 299 tests green; deployment contracts: 4 Python cases plus Helm/release shell suites green.
|
||||
|
||||
Repair 1 results and intermediate failures are recorded in acceptance.md and the appended worker report; earlier broad-suite/site-build limitations remain explicit.
|
||||
|
||||
## Repair 2
|
||||
|
||||
- [x] Save runtime RED for malformed-chunked 401 response-body exceptions in both shipped logging formats, including captured stderr disclosure despite permit recovery.
|
||||
- [x] Verify actual managed 1.31 builder API and dispatcher factory; contain only owned dispatcher uncaught failures with a private ThreadGroup, no global default-handler mutation or BOM upgrade.
|
||||
- [x] Verify fixed diagnostics, errors/failure accounting, permit recovery, later successful export, lifecycle shutdown and unchanged previous handling of unrelated threads.
|
||||
- [x] Assert complete exported identities/source/visibility/old-new fields for every committed producer row; add four real-producer initial/update source-only/target-only promotion filter cases; narrow persistent rollback claim.
|
||||
- [x] Update operator guide/design/spec with the exact safe-diagnostics scope and residual risks; retain evidence of intermediate fixture failures.
|
||||
- [x] Rerun full focused selector/environment: 307 tests green (81 business); real Collector query 27/9/27; deployment contracts 4/4 plus Helm/release shell suites green; diff and scoped cleanup checked.
|
||||
|
||||
Repair 2 exact commands/counts/evidence and proposed fixup are appended to the worker report. No commit/push/comment was performed.
|
||||
|
||||
## Repair 4
|
||||
|
||||
- [x] Run the unmodified Diagnostics + Trace selector twice in default ordering and twice in diagnostic-first Surefire alphabetical ordering; retain the reproduced MDC failure and XML counts.
|
||||
- [x] Isolate process-global diagnostics in a dedicated, non-reused test fork without removing assertions; final minimal combination passes twice, exact focused selector passes 312 tests, full observability package passes 117.
|
||||
- [x] Add default-health/info metrics exposure passthrough in application, Compose and Helm with validation/schema contracts; prove live super-admin 200, ordinary-user 403, anonymous 401, and default admin 404.
|
||||
- [x] Add POSIX runtime entry points for existing receiver and optional downstream-forwarding Collector, shared private network overlay, repeatable updates/status/stop/disable, protected deployment env files and flat OSS retrieval contracts.
|
||||
- [x] Add Makefile demo entry points with automatic host/container endpoint selection, actual JSON queries and expected-event verification; preserve trial records with append mode and a project-scoped local volume across up/stop/start.
|
||||
- [x] Execute a branch Dockerfile build and real bootstrap-admin publication/download matrix for default off, existing receiver, optional Collector, application endpoint update, Collector downstream update, stop/start and disable on an isolated non-default-port deployment.
|
||||
- [x] Execute actual make dev-server with offline packaging, PostgreSQL/Redis and a demo Collector; execute the containerized backend path and prove new skill records plus retained records after restart.
|
||||
- [x] Extend the real Collector integration test with a restarted HTTP readiness probe and preservation of all 27 original UIDs; handle Docker's changed dynamic host-port binding; final broad focused/package runs include the assertion.
|
||||
- [x] Run fake-Docker lifecycle tests, six Python deployment contracts, relevant existing script suites, Helm contracts/strict lint, VitePress and weekly site validation; record platform prerequisite failures separately from passing reruns.
|
||||
- [x] Rewrite the guide as production integration/local trial, add short site/chart pointers, admin queries/counter meanings, operational updates and cleanup steps; document required release/OSS file upload.
|
||||
- [x] Verify real clean retains deployment files byte-for-byte at mode 600; remove the identified test containers, project volumes/networks and test images, preserve pre-existing images, stop the authorized dind and local relay, and verify owned ports are closed.
|
||||
|
||||
Repair 4 real runs, template-only checks, intermediate fixture failures and remaining deployment limits are recorded in acceptance.md and the worker report. Commits remain local; publication is left to the owner.
|
||||
|
||||
## Repair 4a (independent review)
|
||||
|
||||
- [x] Add and execute RED/GREEN regressions for independent file-ref selection with an existing analytics overlay, remembered private/Collector attachment across disable/enable, first-created Collector network seeding without rewriting an existing env, and the Helm same-origin metrics access documentation contract.
|
||||
- [x] Remove version-to-ref fallback; restore the base lifecycle function; preserve attachment intent independently of collection; seed only a newly created independent env; document both pinned arguments and Helm Service port-forward without ingress changes.
|
||||
- [x] Rerun every runtime shell suite, six Python deployment cases, Helm contracts/strict lint with Python installed, release validation, workflow-security, docs/weekly builds, source/privacy checks and range whitespace checks.
|
||||
- [x] Build the server image and run real private-receiver and optional-Collector disable/enable cycles, verify new publication/download facts and disabled zero-export behavior while attached, and exercise real ps with version but no ref against the file source.
|
||||
- [x] Remove this round's Docker resources, verify baseline image/volume/network sets, close the relay and stop the isolated daemon; record unchanged backend/Kubernetes/remote-publication limits in acceptance and the worker report.
|
||||
74
scripts/business-logs-demo.sh
Normal file
74
scripts/business-logs-demo.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/sh
|
||||
# Source-tree trial only; reuse the release network overlay and pinned demo config.
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
STATE_DIR=${BUSINESS_LOGS_DEMO_STATE_DIR:-$ROOT/.dev/business-logs}
|
||||
COMMAND=${1:-status}
|
||||
[ "$#" -eq 0 ] || shift
|
||||
|
||||
if [ "$COMMAND" = run-host ]; then
|
||||
if [ -f "$STATE_DIR/host-port" ]; then
|
||||
SKILLHUB_BUSINESS_LOGS_ENABLED=true
|
||||
SKILLHUB_BUSINESS_LOGS_ENDPOINT="http://127.0.0.1:$(cat "$STATE_DIR/host-port")/v1/logs"
|
||||
export SKILLHUB_BUSINESS_LOGS_ENABLED SKILLHUB_BUSINESS_LOGS_ENDPOINT
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
export SKILLHUB_BUSINESS_LOGS_NETWORK=${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs-demo}
|
||||
export SKILLHUB_BUSINESS_LOGS_DEMO_PORT=${SKILLHUB_BUSINESS_LOGS_DEMO_PORT:-4318}
|
||||
export SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT=${SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT:-skillhub-business-logs-demo}
|
||||
BACKEND=${BUSINESS_LOGS_BACKEND:-host}
|
||||
compose() {
|
||||
docker compose -p "$SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT" -f "$ROOT/docs/examples/business-event-logs/compose.yaml" "$@"
|
||||
}
|
||||
backend_compose() {
|
||||
: "${BUSINESS_LOGS_BACKEND_ENV_FILE:?provide the backend release env file}"
|
||||
: "${BUSINESS_LOGS_BACKEND_PROJECT:?provide the backend Compose project name}"
|
||||
docker compose -p "$BUSINESS_LOGS_BACKEND_PROJECT" --env-file "$BUSINESS_LOGS_BACKEND_ENV_FILE" \
|
||||
-f "${BUSINESS_LOGS_BACKEND_COMPOSE:-$ROOT/compose.release.yml}" \
|
||||
-f "$ROOT/compose.release.business-logs.yml" "$@"
|
||||
}
|
||||
case "$COMMAND" in
|
||||
up)
|
||||
case "$BACKEND" in host|container) ;; *) echo 'BUSINESS_LOGS_BACKEND must be host or container' >&2; exit 1 ;; esac
|
||||
docker network inspect "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null 2>&1 || docker network create "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null
|
||||
compose up -d --force-recreate
|
||||
if [ "$BACKEND" = host ]; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf '%s\n' "$SKILLHUB_BUSINESS_LOGS_DEMO_PORT" >"$STATE_DIR/host-port"
|
||||
echo 'Collector ready. Start/restart the host backend with make dev-server.'
|
||||
else
|
||||
SKILLHUB_BUSINESS_LOGS_ENABLED=true
|
||||
SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs
|
||||
export SKILLHUB_BUSINESS_LOGS_ENABLED SKILLHUB_BUSINESS_LOGS_ENDPOINT
|
||||
backend_compose up -d --no-deps --force-recreate server
|
||||
fi
|
||||
;;
|
||||
status) compose ps ;;
|
||||
records|verify)
|
||||
command -v jq >/dev/null || { echo 'Install jq to query demo records' >&2; exit 1; }
|
||||
scratch=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-business-logs.XXXXXX")
|
||||
trap 'rm -rf "$scratch"' EXIT HUP INT TERM
|
||||
compose cp business-logs-collector:/data/business-logs.json "$scratch/records.jsonl" >/dev/null
|
||||
if [ "$COMMAND" = records ]; then
|
||||
cat "$scratch/records.jsonl"
|
||||
else
|
||||
jq -e -s --arg event "${BUSINESS_LOGS_EXPECT_EVENT:-skill.download.prepared}" \
|
||||
'any(.[].resourceLogs[].scopeLogs[].logRecords[].attributes[]; .key == "skillhub.event.name" and .value.stringValue == $event)' \
|
||||
"$scratch/records.jsonl" >/dev/null
|
||||
printf 'Verified event.name=%s in actual Collector records\n' "${BUSINESS_LOGS_EXPECT_EVENT:-skill.download.prepared}"
|
||||
fi
|
||||
;;
|
||||
down)
|
||||
rm -f "$STATE_DIR/host-port"
|
||||
if [ "$BACKEND" = container ]; then
|
||||
SKILLHUB_BUSINESS_LOGS_ENABLED=false
|
||||
export SKILLHUB_BUSINESS_LOGS_ENABLED
|
||||
backend_compose up -d --no-deps --force-recreate server
|
||||
fi
|
||||
compose stop
|
||||
echo 'Collector stopped; container records and external network retained. Restart a running host backend to disable collection.'
|
||||
;;
|
||||
*) echo 'Usage: business-logs-demo.sh up|status|records|verify|down|run-host command...' >&2; exit 1 ;;
|
||||
esac
|
||||
|
|
@ -28,6 +28,8 @@ WEBSITE_ID=""
|
|||
ANALYTICS_UPSTREAM=""
|
||||
ANALYTICS_ADAPTER="umami"
|
||||
ANALYTICS_APP_ID=""
|
||||
BUSINESS_LOGS_ENDPOINT=""
|
||||
BUSINESS_LOGS_NETWORK=""
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -115,6 +117,16 @@ while [ "$#" -gt 0 ]; do
|
|||
ANALYTICS_APP_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--logs-endpoint)
|
||||
[ "$#" -ge 2 ] || { echo "Missing value for --logs-endpoint" >&2; exit 1; }
|
||||
BUSINESS_LOGS_ENDPOINT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--logs-network)
|
||||
[ "$#" -ge 2 ] || { echo "Missing value for --logs-network" >&2; exit 1; }
|
||||
BUSINESS_LOGS_NETWORK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: sh runtime.sh [up|down|clean|ps|logs|pull|analytics-up|analytics-enable|analytics-disable|analytics-down|analytics-ps|analytics-logs|analytics-pull] [options]
|
||||
|
|
@ -128,6 +140,17 @@ Analytics:
|
|||
--adapter <name> umami (default) or datafinder (with --app-id)
|
||||
--app-id <integer> Existing DataFinder application ID
|
||||
|
||||
Business Logs (default off):
|
||||
business-logs-up Prepare/start optional forwarder and enable server collection
|
||||
First run creates business-logs/.env; configure downstream and retry
|
||||
business-logs-enable Apply .env.release to server; --logs-endpoint overrides receiver
|
||||
business-logs-disable Disable collection and recreate server; keep receiver and data
|
||||
business-logs-down Stop only the optional Collector; keep files and network
|
||||
business-logs-ps Show collection state and optional Collector status
|
||||
business-logs-logs Follow optional Collector logs
|
||||
--logs-endpoint <url> Full OTLP/HTTP Logs URL (no credentials/query/fragment)
|
||||
--logs-network <name> Attach server to this external private network
|
||||
|
||||
Options:
|
||||
--version <tag> Use a specific image tag, for example v0.1.0
|
||||
--aliyun Use the configured Aliyun mirror registry
|
||||
|
|
@ -167,6 +190,10 @@ ANALYTICS_COMPOSE_FILE="$ANALYTICS_HOME/compose.analytics-umami.yml"
|
|||
ANALYTICS_ENV_FILE="$ANALYTICS_HOME/.env"
|
||||
ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example"
|
||||
ENV_FILE="$SKILLHUB_HOME/.env.release"
|
||||
BUSINESS_LOGS_HOME="$SKILLHUB_HOME/business-logs"
|
||||
BUSINESS_LOGS_ENV_FILE="$BUSINESS_LOGS_HOME/.env"
|
||||
BUSINESS_LOGS_COMPOSE_FILE="$BUSINESS_LOGS_HOME/compose.business-logs-collector.yml"
|
||||
BUSINESS_LOGS_OVERLAY_FILE="$SKILLHUB_HOME/compose.release.business-logs.yml"
|
||||
|
||||
find_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
|
|
@ -362,6 +389,10 @@ prepare_runtime_files() {
|
|||
umask "$old_umask"
|
||||
fi
|
||||
secure_env_file
|
||||
if [ "$COMMAND" = "business-logs-enable" ] || [ "$COMMAND" = "business-logs-up" ] ||
|
||||
[ "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false)" = "true" ]; then
|
||||
download_file "$SKILLHUB_RAW_BASE/compose.release.business-logs.yml" "$BUSINESS_LOGS_OVERLAY_FILE"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then
|
||||
mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}"
|
||||
|
|
@ -425,10 +456,78 @@ run_compose() {
|
|||
# Preserve the normal default network and attach only Web to analytics.
|
||||
set -- -f "$OVERLAY_FILE" "$@"
|
||||
fi
|
||||
if [ "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false)" = "true" ]; then
|
||||
ensure_business_logs_network
|
||||
set -- -f "$BUSINESS_LOGS_OVERLAY_FILE" "$@"
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
$compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
ensure_business_logs_network() {
|
||||
SKILLHUB_BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)"
|
||||
export SKILLHUB_BUSINESS_LOGS_NETWORK
|
||||
if ! docker network inspect "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null 2>&1; then
|
||||
docker network create "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_business_logs_files() {
|
||||
mkdir -p "$BUSINESS_LOGS_HOME"
|
||||
# Root-level names work unchanged on both versioned GitHub raw and flat OSS.
|
||||
download_file "$SKILLHUB_RAW_BASE/compose.business-logs-collector.yml" "$BUSINESS_LOGS_COMPOSE_FILE"
|
||||
download_file "$SKILLHUB_RAW_BASE/business-logs-collector.yaml" "$BUSINESS_LOGS_HOME/business-logs-collector.yaml"
|
||||
if [ ! -f "$BUSINESS_LOGS_ENV_FILE" ]; then
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
{
|
||||
printf 'SKILLHUB_BUSINESS_LOGS_PROJECT=%s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_PROJECT skillhub-business-logs)"
|
||||
printf 'SKILLHUB_BUSINESS_LOGS_NETWORK=%s\n' "${BUSINESS_LOGS_NETWORK:-$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)}"
|
||||
printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=\n'
|
||||
printf "SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'\n"
|
||||
} >"$BUSINESS_LOGS_ENV_FILE"
|
||||
umask "$old_umask"
|
||||
fi
|
||||
chmod 600 "$BUSINESS_LOGS_ENV_FILE"
|
||||
}
|
||||
|
||||
run_business_logs_compose() {
|
||||
# The independent file owns its identity. Inherited Compose variables cannot redirect it.
|
||||
SKILLHUB_BUSINESS_LOGS_PROJECT="$(get_env_value SKILLHUB_BUSINESS_LOGS_PROJECT skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")"
|
||||
SKILLHUB_BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")"
|
||||
export SKILLHUB_BUSINESS_LOGS_NETWORK
|
||||
compose_cmd="$(find_compose)"
|
||||
# shellcheck disable=SC2086
|
||||
$compose_cmd -p "$SKILLHUB_BUSINESS_LOGS_PROJECT" --env-file "$BUSINESS_LOGS_ENV_FILE" -f "$BUSINESS_LOGS_COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
validate_business_logs_endpoint() {
|
||||
case "$1" in
|
||||
http://*/*|https://*/*) ;;
|
||||
*) echo "Business Logs requires a full HTTP(S) Logs URL" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$1" in
|
||||
*'@'*|*'?'*|*'#'*|*[[:space:]]*|*'\'*)
|
||||
echo "Business Logs URL must not contain credentials, query, fragment or whitespace" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
enable_business_logs() {
|
||||
receiver_endpoint="${BUSINESS_LOGS_ENDPOINT:-$(get_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT)}"
|
||||
validate_business_logs_endpoint "$receiver_endpoint"
|
||||
if [ -n "$BUSINESS_LOGS_NETWORK" ]; then
|
||||
case "$BUSINESS_LOGS_NETWORK" in
|
||||
*[!A-Za-z0-9_.-]*|[-.]* ) echo "Invalid Business Logs network name" >&2; exit 1 ;;
|
||||
esac
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK "$BUSINESS_LOGS_NETWORK"
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED true
|
||||
fi
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT "$receiver_endpoint"
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_ENABLED true
|
||||
run_compose up -d --no-deps --force-recreate server
|
||||
echo "Business Logs enabled; produce a new event and query the receiver."
|
||||
}
|
||||
|
||||
ensure_analytics_identity() {
|
||||
# Once initialized, the independent directory owns both identifiers. Neither
|
||||
# a recreated release file nor inherited Compose variables may redirect it.
|
||||
|
|
@ -625,7 +724,11 @@ EOF
|
|||
;;
|
||||
clean)
|
||||
run_compose down
|
||||
if [ -d "$ANALYTICS_HOME" ]; then
|
||||
if [ -d "$BUSINESS_LOGS_HOME" ] || [ -n "$(get_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT)" ]; then
|
||||
# Retain operator credentials, Collector config and identities for safe restart.
|
||||
rm -f "$COMPOSE_FILE" "$BUSINESS_LOGS_OVERLAY_FILE" "$ENV_EXAMPLE_FILE"
|
||||
echo "Downloaded release files removed; operator environment and business-logs files retained."
|
||||
elif [ -d "$ANALYTICS_HOME" ]; then
|
||||
# Keep independent platform credentials and files; never strand its database.
|
||||
rm -f "$COMPOSE_FILE" "$OVERLAY_FILE" "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
echo "SkillHub files removed; independent analytics files retained in $ANALYTICS_HOME"
|
||||
|
|
@ -642,6 +745,47 @@ EOF
|
|||
pull)
|
||||
run_compose pull
|
||||
;;
|
||||
business-logs-up)
|
||||
prepare_business_logs_files
|
||||
downstream_endpoint="$(get_env_value SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT "" "$BUSINESS_LOGS_ENV_FILE")"
|
||||
if [ -z "$downstream_endpoint" ]; then
|
||||
echo "Configure SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT (OTLP base URL) and optional JSON headers in $BUSINESS_LOGS_ENV_FILE, then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
validate_business_logs_endpoint "${downstream_endpoint%/}/v1/logs"
|
||||
# Read the independent project's stored network; do not overwrite operator secrets.
|
||||
BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")"
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK "$BUSINESS_LOGS_NETWORK"
|
||||
ensure_business_logs_network
|
||||
run_business_logs_compose config --quiet
|
||||
run_business_logs_compose up -d --force-recreate
|
||||
BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs
|
||||
enable_business_logs
|
||||
;;
|
||||
business-logs-enable)
|
||||
enable_business_logs
|
||||
;;
|
||||
business-logs-disable)
|
||||
set_env_value SKILLHUB_BUSINESS_LOGS_ENABLED false
|
||||
# Attachment intent survives collection being disabled, so enable can reuse it.
|
||||
run_compose up -d --no-deps --force-recreate server
|
||||
echo "Business Logs disabled; receiver data and network retained."
|
||||
;;
|
||||
business-logs-ps)
|
||||
printf 'Business Logs enabled: %s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_ENABLED false)"
|
||||
if [ -f "$BUSINESS_LOGS_ENV_FILE" ]; then
|
||||
run_business_logs_compose ps
|
||||
else
|
||||
echo "No optional Collector; the receiver is managed externally."
|
||||
fi
|
||||
;;
|
||||
business-logs-down|business-logs-logs)
|
||||
[ -f "$BUSINESS_LOGS_ENV_FILE" ] || { echo "No optional Collector; run business-logs-up first" >&2; exit 1; }
|
||||
case "$COMMAND" in
|
||||
business-logs-down) run_business_logs_compose stop ;;
|
||||
business-logs-logs) run_business_logs_compose logs -f ;;
|
||||
esac
|
||||
;;
|
||||
analytics-up)
|
||||
prepare_analytics_files
|
||||
ensure_analytics_network
|
||||
|
|
|
|||
74
scripts/tests/runtime-business-logs-test.sh
Normal file
74
scripts/tests/runtime-business-logs-test.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
TMP=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-business-logs-test.XXXXXX")
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir -p "$TMP/bin" "$TMP/home"
|
||||
cat >"$TMP/bin/docker" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >>"$DOCKER_LOG"
|
||||
case "$*" in
|
||||
'network inspect '*) [ -f "$NETWORK_MARKER" ] ;;
|
||||
'network create '*) touch "$NETWORK_MARKER" ;;
|
||||
*' cp business-logs-collector:'*)
|
||||
for last do :; done
|
||||
printf '%s\n' '{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"attributes":[{"key":"skillhub.event.name","value":{"stringValue":"skill.download.prepared"}}]}]}]}]}' >"$last"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$TMP/bin/docker"
|
||||
export DOCKER_LOG="$TMP/docker.log" NETWORK_MARKER="$TMP/network" SKILLHUB_HOME="$TMP/home" SKILLHUB_RAW_BASE="file://$ROOT" PATH="$TMP/bin:$PATH"
|
||||
run() { sh "$ROOT/scripts/runtime.sh" "$@" >"$TMP/output" 2>&1; }
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
mode() { if stat -c %a "$1" >/dev/null 2>&1; then stat -c %a "$1"; else stat -f %Lp "$1"; fi; }
|
||||
run up --no-scanner
|
||||
! grep -q 'compose.business-logs\|network create' "$DOCKER_LOG" || fail 'default started collection'
|
||||
grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=false$' "$TMP/home/.env.release" || fail 'not default off'
|
||||
if run business-logs-enable --logs-endpoint 'https://fake:secret@receiver.invalid/v1/logs'; then fail 'credential URL accepted'; fi
|
||||
! grep -q 'fake:secret' "$TMP/output" || fail 'URL leaked'
|
||||
if run business-logs-enable --logs-endpoint 'http://receiver\invalid/v1/logs'; then fail 'backslash URL accepted'; fi
|
||||
run business-logs-enable --logs-endpoint https://receiver.example.com/v1/logs
|
||||
grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=true$' "$TMP/home/.env.release" || fail 'existing receiver not enabled'
|
||||
grep -q '^SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=false$' "$TMP/home/.env.release" || fail 'public receiver joined network'
|
||||
run business-logs-ps
|
||||
grep -q 'Business Logs enabled: true' "$TMP/output" || fail 'existing receiver status missing'
|
||||
grep -q 'managed externally' "$TMP/output" || fail 'existing receiver status requires Collector'
|
||||
run business-logs-enable --logs-network synthetic-business-logs --logs-endpoint http://existing:4318/v1/logs
|
||||
run up --no-scanner
|
||||
grep -q 'compose.release.business-logs.yml up -d postgres' "$DOCKER_LOG" || fail 'plain up lost network overlay'
|
||||
[ "$(grep -c '^network create ' "$DOCKER_LOG")" = 1 ] || fail 'network not idempotent'
|
||||
if run business-logs-up; then fail 'missing downstream accepted'; fi
|
||||
[ "$(mode "$TMP/home/business-logs/.env")" = 600 ] || fail 'Collector env permissions'
|
||||
cat >>"$TMP/home/business-logs/.env" <<'ENV'
|
||||
SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com
|
||||
SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{"Authorization":"Bearer SYNTHETIC-NOT-A-SECRET"}'
|
||||
ENV
|
||||
before=$(shasum -a 256 "$TMP/home/business-logs/.env")
|
||||
run business-logs-up
|
||||
run business-logs-up
|
||||
[ "$(shasum -a 256 "$TMP/home/business-logs/.env")" = "$before" ] || fail 'Collector secrets overwritten'
|
||||
! grep -q SYNTHETIC-NOT-A-SECRET "$TMP/output" "$DOCKER_LOG" || fail 'header leaked'
|
||||
grep -q 'compose.business-logs-collector.yml up -d --force-recreate' "$DOCKER_LOG" || fail 'Collector update not applied'
|
||||
grep -q '^SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs$' "$TMP/home/.env.release" || fail 'wrong internal endpoint'
|
||||
run business-logs-down
|
||||
run business-logs-ps
|
||||
run business-logs-up
|
||||
grep -q 'compose.business-logs-collector.yml stop' "$DOCKER_LOG" || fail 'stop missing'
|
||||
! grep -q 'compose.business-logs-collector.yml down' "$DOCKER_LOG" || fail 'stop deletes project'
|
||||
run business-logs-disable
|
||||
grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=false$' "$TMP/home/.env.release" || fail 'disable failed'
|
||||
run clean
|
||||
[ -f "$TMP/home/.env.release" ] && [ -f "$TMP/home/business-logs/.env" ] || fail 'clean removed operator secrets'
|
||||
run business-logs-up --aliyun
|
||||
[ -f "$TMP/home/business-logs/business-logs-collector.yaml" ] || fail 'flat OSS retrieval failed'
|
||||
|
||||
export BUSINESS_LOGS_DEMO_STATE_DIR="$TMP/state" SKILLHUB_BUSINESS_LOGS_DEMO_PORT=14318
|
||||
sh "$ROOT/scripts/business-logs-demo.sh" >"$TMP/demo-output"
|
||||
sh "$ROOT/scripts/business-logs-demo.sh" up >"$TMP/demo-output"
|
||||
result=$(sh "$ROOT/scripts/business-logs-demo.sh" run-host sh -c 'printf "%s %s" "$SKILLHUB_BUSINESS_LOGS_ENABLED" "$SKILLHUB_BUSINESS_LOGS_ENDPOINT"')
|
||||
[ "$result" = 'true http://127.0.0.1:14318/v1/logs' ] || fail 'host receive address'
|
||||
export BUSINESS_LOGS_BACKEND=container BUSINESS_LOGS_BACKEND_PROJECT=synthetic-backend BUSINESS_LOGS_BACKEND_ENV_FILE="$TMP/home/.env.release"
|
||||
make -C "$ROOT" business-logs-demo-up business-logs-demo-status business-logs-demo-records business-logs-demo-verify business-logs-demo-down >"$TMP/demo-output"
|
||||
grep -q 'synthetic-backend .*compose.release.business-logs.yml up -d --no-deps --force-recreate server' "$DOCKER_LOG" || fail 'container network overlay missing'
|
||||
[ ! -f "$TMP/state/host-port" ] || fail 'host state not disabled'
|
||||
echo 'runtime-business-logs-test passed (fake-Docker lifecycle and demo contracts)'
|
||||
149
scripts/tests/runtime-review-regression-test.sh
Normal file
149
scripts/tests/runtime-review-regression-test.sh
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env bash
|
||||
# Fake-Docker regressions for independent review; no daemon or network required.
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
TMP=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-runtime-review.XXXXXX")
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir -p "$TMP/bin"
|
||||
export ROOT DOCKER_LOG="$TMP/docker.log" FETCH_LOG="$TMP/fetch.log"
|
||||
export REAL_CURL
|
||||
REAL_CURL=$(command -v curl)
|
||||
cat >"$TMP/bin/docker" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >>"$DOCKER_LOG"
|
||||
exit 0
|
||||
SH
|
||||
cat >"$TMP/bin/curl" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$2" >>"$FETCH_LOG"
|
||||
case "$2" in
|
||||
https://raw.githubusercontent.com/iflytek/skillhub/*)
|
||||
path=${2#https://raw.githubusercontent.com/iflytek/skillhub/}
|
||||
# Record the real ref selection, but retrieve fixture files without a network.
|
||||
exec "$REAL_CURL" "$1" "file://$ROOT/${path#*/}" "$3" "$4"
|
||||
;;
|
||||
*) exec "$REAL_CURL" "$@" ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$TMP/bin/docker" "$TMP/bin/curl"
|
||||
export PATH="$TMP/bin:$PATH" SKILLHUB_RAW_BASE="file://$ROOT"
|
||||
unset SKILLHUB_REF SKILLHUB_VERSION
|
||||
run() { sh "$ROOT/scripts/runtime.sh" "$@" >"$TMP/output" 2>&1; }
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
||||
fresh() {
|
||||
export SKILLHUB_HOME="$TMP/$1"
|
||||
mkdir -p "$SKILLHUB_HOME"
|
||||
: >"$DOCKER_LOG"
|
||||
: >"$FETCH_LOG"
|
||||
}
|
||||
assert_env() {
|
||||
grep -Fxq "$1=$2" "$SKILLHUB_HOME/.env.release" || fail "$1 expected $2"
|
||||
}
|
||||
|
||||
b1() {
|
||||
unset SKILLHUB_RAW_BASE
|
||||
broken=false
|
||||
for ref in main x; do
|
||||
fresh "b1-$ref"
|
||||
# Existing analytics installations fetch this overlay even for ps/down/clean.
|
||||
touch "$SKILLHUB_HOME/compose.release.analytics.yml"
|
||||
if [ "$ref" = main ]; then unset SKILLHUB_REF; else export SKILLHUB_REF="$ref"; fi
|
||||
run ps --version v0.2.25
|
||||
printf 'B1 requested URLs (expected ref=%s):\n' "$ref"
|
||||
cat "$FETCH_LOG"
|
||||
if ! grep -Fxq "https://raw.githubusercontent.com/iflytek/skillhub/$ref/compose.release.analytics.yml" "$FETCH_LOG"; then
|
||||
printf 'FAIL: --version changed file ref from %s\n' "$ref"
|
||||
broken=true
|
||||
fi
|
||||
done
|
||||
# An env ref equal to the image tag must still be carried into lifecycle commands.
|
||||
run up --version x --no-scanner
|
||||
if ! grep -q 'sh -s -- ps --ref x --version x' "$TMP/output"; then
|
||||
printf 'FAIL: lifecycle omitted environment ref equal to version\n'
|
||||
broken=true
|
||||
fi
|
||||
[ "$broken" = false ] || fail 'B1 ref/lifecycle regression'
|
||||
printf 'PASS B1: analytics ps keeps main/env ref; lifecycle preserves --ref x with --version x\n'
|
||||
}
|
||||
|
||||
b2() {
|
||||
export SKILLHUB_RAW_BASE="file://$ROOT"
|
||||
unset SKILLHUB_REF
|
||||
broken=false
|
||||
for receiver in private collector public; do
|
||||
fresh "b2-$receiver"
|
||||
run up --no-scanner
|
||||
case "$receiver" in
|
||||
private) run business-logs-enable --logs-network synthetic-review-private --logs-endpoint http://existing:4318/v1/logs ;;
|
||||
collector)
|
||||
if run business-logs-up; then fail 'unconfigured downstream accepted'; fi
|
||||
printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com\n' >>"$SKILLHUB_HOME/business-logs/.env"
|
||||
run business-logs-up
|
||||
;;
|
||||
public) run business-logs-enable --logs-endpoint https://receiver.example.com/v1/logs ;;
|
||||
esac
|
||||
run business-logs-disable
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_ENABLED false
|
||||
: >"$DOCKER_LOG"
|
||||
run business-logs-enable
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_ENABLED true
|
||||
if [ "$receiver" = public ]; then
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false
|
||||
! grep -q 'compose.release.business-logs.yml\|^network ' "$DOCKER_LOG" || fail 'public HTTPS receiver requires private network'
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT https://receiver.example.com/v1/logs
|
||||
else
|
||||
grep 'compose .*up -d --no-deps --force-recreate server' "$DOCKER_LOG"
|
||||
if ! grep -Fxq 'SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=true' "$SKILLHUB_HOME/.env.release"; then
|
||||
printf 'FAIL: B2 %s re-enable lost saved network attachment\n' "$receiver"
|
||||
broken=true
|
||||
continue
|
||||
fi
|
||||
grep -q 'compose.release.business-logs.yml up -d --no-deps --force-recreate server' "$DOCKER_LOG" || fail "$receiver re-enable lost network overlay"
|
||||
if [ "$receiver" = private ]; then
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_NETWORK synthetic-review-private
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT http://existing:4318/v1/logs
|
||||
else
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT http://business-logs-collector:4318/v1/logs
|
||||
fi
|
||||
fi
|
||||
printf 'PASS B2 %s: enable -> disable -> enable restores saved state\n' "$receiver"
|
||||
done
|
||||
[ "$broken" = false ] || fail 'B2 private/Collector network regression'
|
||||
}
|
||||
|
||||
n1() {
|
||||
export SKILLHUB_RAW_BASE="file://$ROOT"
|
||||
unset SKILLHUB_REF
|
||||
fresh n1
|
||||
if run business-logs-up --logs-network synthetic-review-seeded; then fail 'unconfigured downstream accepted'; fi
|
||||
grep '^SKILLHUB_BUSINESS_LOGS_NETWORK=' "$SKILLHUB_HOME/business-logs/.env"
|
||||
grep -Fxq 'SKILLHUB_BUSINESS_LOGS_NETWORK=synthetic-review-seeded' "$SKILLHUB_HOME/business-logs/.env" || fail 'first-time --logs-network ignored'
|
||||
printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com\n' >>"$SKILLHUB_HOME/business-logs/.env"
|
||||
before=$(shasum -a 256 "$SKILLHUB_HOME/business-logs/.env")
|
||||
run business-logs-up --logs-network synthetic-review-ignored
|
||||
[ "$(shasum -a 256 "$SKILLHUB_HOME/business-logs/.env")" = "$before" ] || fail 'existing Collector env rewritten'
|
||||
assert_env SKILLHUB_BUSINESS_LOGS_NETWORK synthetic-review-seeded
|
||||
grep -q 'network inspect synthetic-review-seeded' "$DOCKER_LOG" || fail 'seeded network not used'
|
||||
printf 'PASS N1: first creation seeds CLI network; existing env bytes and saved network retained\n'
|
||||
}
|
||||
|
||||
n2() {
|
||||
export SKILLHUB_RAW_BASE="file://$ROOT"
|
||||
fresh n2
|
||||
run ps
|
||||
# Documentation contract only: Helm access must bypass ingress and preserve
|
||||
# the forwarded origin in the shared login + metrics commands.
|
||||
grep -Fq 'kubectl port-forward -n skillhub service/skillhub-server 18080:8080' "$ROOT/docs/business-event-logs.md" || fail 'Helm metrics Service port-forward missing'
|
||||
grep -Fq 'BASE=http://127.0.0.1:18080' "$ROOT/docs/business-event-logs.md" || fail 'Helm forwarded login/query origin missing'
|
||||
grep -Fq 'BASE=${BASE:-http://127.0.0.1:8080}' "$ROOT/docs/business-event-logs.md" || fail 'shared query overwrites Helm forwarded origin'
|
||||
grep -Fq '"$BASE/api/v1/auth/local/login"' "$ROOT/docs/business-event-logs.md" || fail 'login origin differs'
|
||||
grep -Fq '"$BASE/actuator/metrics/skillhub.business.logs.enqueued"' "$ROOT/docs/business-event-logs.md" || fail 'metrics origin differs'
|
||||
printf 'PASS N2: Helm metrics Service port-forward and same-origin admin query documented (contract, not live Kubernetes)\n'
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
b1|b2|n1|n2) "$1" ;;
|
||||
all) b1; b2; n1; n2 ;;
|
||||
*) fail 'expected b1, b2, n1, n2 or all' ;;
|
||||
esac
|
||||
113
scripts/tests/test_business_logs_config.py
Normal file
113
scripts/tests/test_business_logs_config.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deployment contract checks using only synthetic inputs; never load local credential files."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE = """SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.invalid
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=synthetic-validation-only-not-a-secret-value
|
||||
POSTGRES_DB=skillhub
|
||||
POSTGRES_USER=skillhub
|
||||
POSTGRES_PASSWORD=synthetic-validation-only-not-a-secret
|
||||
BOOTSTRAP_ADMIN_ENABLED=false
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SKILLHUB_STORAGE_PROVIDER=local
|
||||
"""
|
||||
|
||||
|
||||
class BusinessLogsDeploymentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory(prefix="skillhub-1009-config-")
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.fixture = Path(self.directory.name) / "synthetic.env"
|
||||
self.fixture.write_text(BASE)
|
||||
self.environment = dict(os.environ, COMPOSE_DISABLE_ENV_FILE="true")
|
||||
# Explicitly pin test settings rather than inheriting operator telemetry configuration.
|
||||
for key in list(self.environment):
|
||||
if key.startswith("SKILLHUB_BUSINESS_LOGS_"):
|
||||
del self.environment[key]
|
||||
|
||||
def run_command(self, *command):
|
||||
return subprocess.run(command, cwd=ROOT, env=self.environment, capture_output=True, text=True, timeout=60)
|
||||
|
||||
def validate(self, extra):
|
||||
self.fixture.write_text(BASE + extra)
|
||||
return self.run_command("sh", "scripts/validate-release-config.sh", str(self.fixture))
|
||||
|
||||
def test_release_default_off_and_enabled_requires_endpoint(self):
|
||||
self.assertEqual(self.validate("").returncode, 0)
|
||||
result = self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\n")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("SKILLHUB_BUSINESS_LOGS_ENDPOINT is required", result.stderr)
|
||||
self.assertEqual(self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=http://collector:4318/v1/logs\n").returncode, 0)
|
||||
|
||||
def test_metrics_opt_in_and_wildcard_rejection(self):
|
||||
self.assertEqual(self.validate("SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus\n").returncode, 0)
|
||||
self.assertNotEqual(self.validate("SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=*\n").returncode, 0)
|
||||
|
||||
def test_release_rejects_secret_bearing_url_without_printing_it(self):
|
||||
result = self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=https://user:fake-secret@receiver.invalid/v1/logs?token=fake-query\n")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("fake-secret", result.stdout + result.stderr)
|
||||
self.assertNotIn("fake-query", result.stdout + result.stderr)
|
||||
|
||||
def test_compose_defaults_and_explicit_forwarding(self):
|
||||
command = ("docker", "compose", "--env-file", str(self.fixture), "-p", "skillhub-1009-config", "-f", "compose.release.yml", "config", "--format", "json")
|
||||
result = self.run_command(*command)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
environment = json.loads(result.stdout)["services"]["server"]["environment"]
|
||||
self.assertEqual(environment["SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE"], "health,info")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENABLED"], "false")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENDPOINT"], "")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE"], "2048")
|
||||
self.fixture.write_text(BASE + "SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=http://collector:4318/v1/logs\nSKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST=7,12\nSKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=\n")
|
||||
result = self.run_command(*command)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
environment = json.loads(result.stdout)["services"]["server"]["environment"]
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENABLED"], "true")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENDPOINT"], "http://collector:4318/v1/logs")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST"], "7,12")
|
||||
self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST"], "")
|
||||
|
||||
def test_collector_downstream_and_private_demo_network_contracts(self):
|
||||
self.fixture.write_text(BASE + "SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://receiver.example.com\nSKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'\n")
|
||||
command = ("docker", "compose", "--env-file", str(self.fixture), "-p", "skillhub-business-contract")
|
||||
result = self.run_command(*command, "-f", "compose.business-logs-collector.yml", "config", "--format", "json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
production = json.loads(result.stdout)
|
||||
service = production["services"]["business-logs-collector"]
|
||||
self.assertFalse(service.get("ports"))
|
||||
self.assertEqual(service["environment"]["SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS"], "{}")
|
||||
self.assertEqual(service["mem_limit"], "134217728")
|
||||
self.assertTrue(production["networks"]["business-logs"]["external"])
|
||||
result = self.run_command(*command, "-f", "docs/examples/business-event-logs/compose.yaml", "config", "--format", "json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
demo = json.loads(result.stdout)["services"]["business-logs-collector"]
|
||||
self.assertEqual(demo["ports"][0]["host_ip"], "127.0.0.1")
|
||||
self.assertNotIn("container_name", demo)
|
||||
self.assertTrue(any(volume["target"] == "/data" and volume["type"] == "volume" for volume in demo["volumes"]))
|
||||
self.assertIn("append: true", (ROOT / "docs/examples/business-event-logs/collector.yaml").read_text())
|
||||
|
||||
@unittest.skipUnless(shutil.which("helm"), "helm executable required")
|
||||
def test_helm_defaults_secret_reference_and_invalid_enabled_config(self):
|
||||
command = ("helm", "template", "skillhub-1009-config", "charts/skillhub", "-f", "charts/skillhub/tests/test-values.yaml", "--show-only", "templates/server-deployment.yaml")
|
||||
result = self.run_command(*command)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_ENABLED\\s+value: "false"')
|
||||
self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_ENDPOINT\\s+value: ""')
|
||||
self.assertNotIn("name: SKILLHUB_BUSINESS_LOGS_HEADERS", result.stdout)
|
||||
result = self.run_command(*command, "--set", "server.businessLogs.enabled=true")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
result = self.run_command(*command, "--set", "server.businessLogs.enabled=true", "--set", "server.businessLogs.endpoint=http://collector:4318/v1/logs", "--set", "server.businessLogs.headersSecretRef.name=analytics-credentials")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.count("name: SKILLHUB_BUSINESS_LOGS_HEADERS"), 1)
|
||||
self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_HEADERS\\s+valueFrom:\\s+secretKeyRef:\\s+name: "analytics-credentials"\\s+key: "headers"\\s+optional: false')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -347,6 +347,22 @@ validate_boolean SESSION_COOKIE_SECURE
|
|||
validate_boolean BOOTSTRAP_ADMIN_ENABLED
|
||||
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
|
||||
validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED
|
||||
validate_boolean SKILLHUB_BUSINESS_LOGS_ENABLED
|
||||
case "${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info}" in
|
||||
health,info|health,info,metrics,prometheus) ;;
|
||||
*) error "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE must be health,info or health,info,metrics,prometheus" ;;
|
||||
esac
|
||||
# Do not echo the endpoint or authentication headers. Full URL/budget validation is at startup.
|
||||
if [ "${SKILLHUB_BUSINESS_LOGS_ENABLED:-false}" = "true" ]; then
|
||||
require_non_empty SKILLHUB_BUSINESS_LOGS_ENDPOINT
|
||||
case "${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-}" in
|
||||
http://*|https://*) ;;
|
||||
*) error "SKILLHUB_BUSINESS_LOGS_ENDPOINT requires an HTTP(S) Logs URL" ;;
|
||||
esac
|
||||
case "${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-}" in
|
||||
*'@'*|*'?'*|*'#'*) error "SKILLHUB_BUSINESS_LOGS_ENDPOINT must not contain credentials, query or fragment" ;;
|
||||
esac
|
||||
fi
|
||||
validate_boolean SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING
|
||||
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
|
||||
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-exporter-otlp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-sdk-logs</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.skywalking</groupId>
|
||||
<artifactId>apm-toolkit-logback-1.x</artifactId>
|
||||
|
|
@ -164,6 +168,46 @@
|
|||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
<configuration>
|
||||
<excludedGroups>process-global-diagnostics,process-global-otel-context</excludedGroups>
|
||||
</configuration>
|
||||
</execution>
|
||||
<!-- Raw OTel contexts initialize JVM-global ContextStorage before Boot can
|
||||
register its MDC wrapper. Each tagged class gets a fresh process. -->
|
||||
<execution>
|
||||
<id>isolated-otel-context</id>
|
||||
<goals><goal>test</goal></goals>
|
||||
<configuration>
|
||||
<groups>process-global-otel-context</groups>
|
||||
<includes>
|
||||
<include>**/BusinessLogs*Test.java</include>
|
||||
</includes>
|
||||
<failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
|
||||
<forkCount>1</forkCount>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>isolated-diagnostics</id>
|
||||
<goals><goal>test</goal></goals>
|
||||
<configuration>
|
||||
<groups>process-global-diagnostics</groups>
|
||||
<includes>
|
||||
<include>**/BusinessLogsDiagnosticsTest.java</include>
|
||||
</includes>
|
||||
<failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
|
||||
<forkCount>1</forkCount>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.SkillBusinessEvent;
|
||||
import io.opentelemetry.api.trace.Span;
|
||||
import io.opentelemetry.api.trace.SpanContext;
|
||||
import io.opentelemetry.context.Context;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/** Synchronous O(1) snapshot capture; only SDK admission runs after commit, never network IO. */
|
||||
public final class BusinessEventListener {
|
||||
private final BusinessEventMapper mapper;
|
||||
private final BusinessLogsPipeline pipeline;
|
||||
private final BusinessLogsMetrics metrics;
|
||||
|
||||
BusinessEventListener(BusinessEventMapper mapper, BusinessLogsPipeline pipeline, BusinessLogsMetrics metrics) {
|
||||
this.mapper = mapper;
|
||||
this.pipeline = pipeline;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onEvent(SkillBusinessEvent event) {
|
||||
try {
|
||||
mapper.map(event).ifPresent(record -> {
|
||||
SpanContext span = Span.current().getSpanContext();
|
||||
// Retain only valid standard span identity, never baggage or arbitrary request/MDC data.
|
||||
Context context = span.isValid() ? Context.root().with(Span.wrap(span)) : Context.root();
|
||||
if (TransactionSynchronizationManager.isActualTransactionActive()
|
||||
&& TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override public void afterCommit() { pipeline.emit(record, context); }
|
||||
});
|
||||
} else {
|
||||
pipeline.emit(record, context);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException ex) {
|
||||
metrics.error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.*;
|
||||
import io.opentelemetry.api.common.Attributes;
|
||||
import io.opentelemetry.api.common.AttributesBuilder;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Sole data boundary. Never reflect/serialize an event or look up an entity here. */
|
||||
public final class BusinessEventMapper {
|
||||
public static final Set<String> EVENT_NAMES = Set.of("skill.publication.effective", "skill.download.prepared",
|
||||
"skill.review.approved", "skill.review.rejected", "skill.promotion.approved", "skill.promotion.rejected",
|
||||
"skill.status.changed", "skill.visibility.changed", "skill.version.yanked");
|
||||
private static final Set<String> SOURCES = Set.of("auto_publish", "private_confirm", "review", "promotion",
|
||||
"download", "governance", "visibility");
|
||||
private final Set<String> events;
|
||||
private final Set<Long> namespaces;
|
||||
|
||||
BusinessEventMapper(BusinessLogsProperties properties) {
|
||||
events = Set.copyOf(properties.getEventAllowlist());
|
||||
namespaces = Set.copyOf(properties.getNamespaceAllowlist());
|
||||
}
|
||||
|
||||
public Optional<MappedRecord> map(SkillBusinessEvent event) {
|
||||
BusinessEventSnapshot snapshot = event.snapshot();
|
||||
if (snapshot == null || snapshot.namespaceId() == null || snapshot.visibility() == null
|
||||
|| snapshot.occurredAt() == null || event.skillId() == null
|
||||
|| snapshot.namespaceId() <= 0 || event.skillId() <= 0
|
||||
|| snapshot.source() == null || snapshot.actorType() == null
|
||||
|| !SOURCES.contains(snapshot.source())
|
||||
|| !Set.of("user", "system").contains(snapshot.actorType())
|
||||
|| (!namespaces.isEmpty() && !namespaces.contains(snapshot.namespaceId()))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
AttributesBuilder attributes = Attributes.builder();
|
||||
String name;
|
||||
switch (event) {
|
||||
case SkillPublishedEvent e -> { name = "skill.publication.effective"; id(attributes, "version", e.versionId()); }
|
||||
case SkillDownloadedEvent e -> { name = "skill.download.prepared"; id(attributes, "version", e.versionId()); }
|
||||
case ReviewApprovedEvent e -> { name = "skill.review.approved"; id(attributes, "version", e.versionId()); id(attributes, "review", e.reviewId()); }
|
||||
case ReviewRejectedEvent e -> { name = "skill.review.rejected"; id(attributes, "version", e.versionId()); id(attributes, "review", e.reviewId()); }
|
||||
case PromotionApprovedEvent e -> { name = "skill.promotion.approved"; id(attributes, "promotion", e.promotionId()); }
|
||||
case PromotionRejectedEvent e -> { name = "skill.promotion.rejected"; id(attributes, "promotion", e.promotionId()); }
|
||||
case SkillStatusChangedEvent e -> {
|
||||
if (e.oldStatus() == null || e.newStatus() == null || e.oldStatus() == e.newStatus()) { return Optional.empty(); }
|
||||
name = "skill.status.changed";
|
||||
attributes.put("skillhub.status.old", e.oldStatus().name()).put("skillhub.status.new", e.newStatus().name());
|
||||
}
|
||||
case SkillVisibilityChangedEvent e -> {
|
||||
if (e.previousVisibility() == null || e.visibility() == null || e.previousVisibility() == e.visibility()) { return Optional.empty(); }
|
||||
name = "skill.visibility.changed";
|
||||
attributes.put("skillhub.visibility.old", e.previousVisibility().name()).put("skillhub.visibility.new", e.visibility().name());
|
||||
}
|
||||
case SkillVersionYankedEvent e -> {
|
||||
if (!e.wasPublished()) { return Optional.empty(); }
|
||||
name = "skill.version.yanked";
|
||||
id(attributes, "version", e.versionId());
|
||||
}
|
||||
default -> { return Optional.empty(); }
|
||||
}
|
||||
if (!events.contains(name)) { return Optional.empty(); }
|
||||
String uid = UUID.randomUUID().toString();
|
||||
attributes.put("skillhub.event.name", name).put("skillhub.skill.id", event.skillId())
|
||||
.put("skillhub.namespace.id", snapshot.namespaceId()).put("skillhub.visibility", snapshot.visibility().name())
|
||||
.put("skillhub.actor.type", snapshot.actorType()).put("skillhub.source", snapshot.source())
|
||||
.put("log.record.uid", uid);
|
||||
if ("user".equals(snapshot.actorType()) && snapshot.actorId() != null && !snapshot.actorId().isBlank()) {
|
||||
attributes.put("user.id", bounded(snapshot.actorId()));
|
||||
}
|
||||
return Optional.of(new MappedRecord(snapshot.occurredAt(), attributes.build(), uid));
|
||||
}
|
||||
|
||||
static String bounded(String value) {
|
||||
return value.length() <= 256 ? value : value.substring(0, 256);
|
||||
}
|
||||
|
||||
private static void id(AttributesBuilder attributes, String kind, Long value) {
|
||||
if (value != null) { attributes.put("skillhub." + kind + ".id", value); }
|
||||
}
|
||||
|
||||
public record MappedRecord(Instant timestamp, Attributes attributes, String uid) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.opentelemetry.api.common.Attributes;
|
||||
import io.opentelemetry.api.metrics.MeterProvider;
|
||||
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
|
||||
import io.opentelemetry.sdk.common.export.RetryPolicy;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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;
|
||||
|
||||
/** Separate signal lifecycle; no global OTel registration or tracing beans. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(BusinessLogsProperties.class)
|
||||
@ConditionalOnProperty(prefix = "skillhub.observability.business-logs", name = "enabled", havingValue = "true")
|
||||
public class BusinessLogsConfiguration {
|
||||
@Bean
|
||||
BusinessLogsMetrics businessLogsMetrics(MeterRegistry registry) { return new BusinessLogsMetrics(registry); }
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
BusinessLogsPipeline businessLogsPipeline(BusinessLogsProperties properties, BusinessLogsMetrics metrics,
|
||||
Environment environment) {
|
||||
properties.validate();
|
||||
var builder = OtlpHttpLogRecordExporter.builder().setEndpoint(properties.getEndpoint())
|
||||
.setTimeout(properties.getTimeout()).setCompression(properties.getCompression())
|
||||
.setMeterProvider(MeterProvider.noop());
|
||||
properties.parsedHeaders().forEach(builder::addHeader);
|
||||
if (properties.getRetryMaxAttempts() > 1) {
|
||||
builder.setRetryPolicy(RetryPolicy.builder().setMaxAttempts(properties.getRetryMaxAttempts())
|
||||
.setInitialBackoff(properties.getRetryInitialBackoff()).setMaxBackoff(properties.getRetryMaxBackoff())
|
||||
.setBackoffMultiplier(2).build());
|
||||
}
|
||||
Resource resource = Resource.create(Attributes.builder()
|
||||
.put("service.name", BusinessEventMapper.bounded(environment.getProperty("spring.application.name", "skillhub")))
|
||||
.put("service.version", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-version", "unknown")))
|
||||
.put("deployment.environment", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-environment", "local")))
|
||||
.build());
|
||||
var exporter = BusinessLogsExporterBoundary.build(builder::build, metrics);
|
||||
try {
|
||||
return new BusinessLogsPipeline(properties, resource, exporter, metrics);
|
||||
} catch (RuntimeException ex) {
|
||||
try { exporter.shutdown(); } catch (RuntimeException shutdownException) { metrics.error(); }
|
||||
throw new IllegalStateException("Business Logs pipeline initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
BusinessEventListener businessEventListener(BusinessLogsProperties properties, BusinessLogsPipeline pipeline,
|
||||
BusinessLogsMetrics metrics) {
|
||||
return new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.turbo.TurboFilter;
|
||||
import ch.qos.logback.core.spi.FilterReply;
|
||||
import org.slf4j.Marker;
|
||||
|
||||
/**
|
||||
* Drops raw OTel/OkHttp diagnostics emitted on the business exporter's own network threads.
|
||||
* Those messages can echo receiver bodies or transport exception text containing credentials.
|
||||
* The managed 1.31 sender reports results on its dispatcher threads, which are all created in
|
||||
* {@link BusinessLogsExporterBoundary}; Trace and other exporters share the same logger names but
|
||||
* run on other threads, so their diagnostics keep the logging configuration's normal behavior.
|
||||
*/
|
||||
public final class BusinessLogsDiagnosticsFilter extends TurboFilter {
|
||||
@Override
|
||||
public FilterReply decide(Marker marker, Logger logger, Level level, String format, Object[] params,
|
||||
Throwable throwable) {
|
||||
if (logger == null) { return FilterReply.NEUTRAL; }
|
||||
String name = logger.getName();
|
||||
if ((name.startsWith("io.opentelemetry.") || name.startsWith("okhttp3."))
|
||||
&& Thread.currentThread().getThreadGroup() instanceof BusinessLogsExporterBoundary) {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Private dispatcher exception boundary for the managed OTel 1.31 OkHttp sender.
|
||||
* Its DaemonThreadFactory captures the constructing thread's group. Construct only the exporter
|
||||
* here, never application work or the batch processor. No JVM default handler is changed.
|
||||
*/
|
||||
final class BusinessLogsExporterBoundary extends ThreadGroup {
|
||||
// OkHttp may initialize shared housekeeping threads in this group. They delegate normally and
|
||||
// must not retain a closed pipeline's registry through this otherwise inert group.
|
||||
private final WeakReference<BusinessLogsMetrics> metrics;
|
||||
|
||||
private BusinessLogsExporterBoundary(BusinessLogsMetrics metrics) {
|
||||
super("skillhub-business-logs-http");
|
||||
this.metrics = new WeakReference<>(metrics);
|
||||
}
|
||||
|
||||
static <T> T build(Supplier<T> factory, BusinessLogsMetrics metrics) {
|
||||
var result = new FutureTask<T>(factory::get);
|
||||
Thread initializer = new Thread(new BusinessLogsExporterBoundary(metrics), result,
|
||||
"skillhub-business-logs-http-init");
|
||||
initializer.setDaemon(true);
|
||||
initializer.start();
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
// Construction is local (no receiver IO). Finish ownership transfer even on interruption,
|
||||
// rather than abandoning a newly built exporter and its executor; restore the flag below.
|
||||
while (true) {
|
||||
try { return result.get(); }
|
||||
catch (InterruptedException ex) { interrupted = true; }
|
||||
catch (ExecutionException ex) {
|
||||
// Never attach a potentially credential-bearing SDK construction exception.
|
||||
throw new IllegalStateException("Business Logs exporter initialization failed");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (interrupted) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uncaughtException(Thread thread, Throwable failure) {
|
||||
if (thread.getThreadGroup() == this && thread.getName().startsWith("okhttp-dispatch-")) {
|
||||
// Do not inspect, render or forward the throwable. The export deadline owns failed-batch
|
||||
// accounting/permit release; this counts the callback error and emits only fixed text.
|
||||
BusinessLogsMetrics current = metrics.get();
|
||||
if (current != null) { current.error(); }
|
||||
} else {
|
||||
super.uncaughtException(thread, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Fixed-cardinality counters; diagnostics contain no events, exception text, URLs or credentials. */
|
||||
final class BusinessLogsMetrics {
|
||||
private static final Logger log = LoggerFactory.getLogger(BusinessLogsMetrics.class);
|
||||
private final AtomicLong lastWarning = new AtomicLong();
|
||||
final Counter enqueued;
|
||||
final Counter queueFull;
|
||||
final Counter closed;
|
||||
final Counter errors;
|
||||
final Counter exportFailures;
|
||||
final Counter exported;
|
||||
|
||||
BusinessLogsMetrics(MeterRegistry registry) {
|
||||
enqueued = registry.counter("skillhub.business.logs.enqueued");
|
||||
queueFull = registry.counter("skillhub.business.logs.dropped", "reason", "queue_full");
|
||||
closed = registry.counter("skillhub.business.logs.dropped", "reason", "shutdown");
|
||||
errors = registry.counter("skillhub.business.logs.errors");
|
||||
exportFailures = registry.counter("skillhub.business.logs.export.failures");
|
||||
exported = registry.counter("skillhub.business.logs.exported");
|
||||
}
|
||||
|
||||
void error() {
|
||||
errors.increment();
|
||||
warn();
|
||||
}
|
||||
|
||||
void warn() {
|
||||
long now = System.nanoTime();
|
||||
long last = lastWarning.get();
|
||||
if ((last == 0 || now - last >= java.util.concurrent.TimeUnit.MINUTES.toNanos(1))
|
||||
&& lastWarning.compareAndSet(last, now)) {
|
||||
log.warn("Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import io.opentelemetry.api.logs.Logger;
|
||||
import io.opentelemetry.api.metrics.MeterProvider;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.sdk.common.CompletableResultCode;
|
||||
import io.opentelemetry.sdk.logs.LogLimits;
|
||||
import io.opentelemetry.sdk.logs.LogRecordProcessor;
|
||||
import io.opentelemetry.sdk.logs.ReadWriteLogRecord;
|
||||
import io.opentelemetry.sdk.logs.SdkLoggerProvider;
|
||||
import io.opentelemetry.sdk.logs.data.LogRecordData;
|
||||
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor;
|
||||
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Dedicated SDK pipeline. Admission permits bound queued + in-flight records together, conservatively
|
||||
* below the SDK queue capacity, providing exact admission metrics without accessing SDK internals.
|
||||
* All network batching/retry remains in the standard SDK/exporter.
|
||||
*/
|
||||
public final class BusinessLogsPipeline implements AutoCloseable {
|
||||
private final SdkLoggerProvider provider;
|
||||
private final Logger logger;
|
||||
private final LogRecordExporter exporter;
|
||||
private final BusinessLogsMetrics metrics;
|
||||
private final Duration shutdownTimeout;
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
private final ScheduledThreadPoolExecutor exportDeadlines;
|
||||
private final Set<CompletableResultCode> pendingExports = ConcurrentHashMap.newKeySet();
|
||||
|
||||
BusinessLogsPipeline(BusinessLogsProperties properties, Resource resource,
|
||||
LogRecordExporter exporter, BusinessLogsMetrics metrics) {
|
||||
this.exporter = exporter;
|
||||
this.metrics = metrics;
|
||||
this.shutdownTimeout = properties.getShutdownTimeout();
|
||||
exportDeadlines = new ScheduledThreadPoolExecutor(1, task -> {
|
||||
Thread thread = new Thread(task, "skillhub-business-logs-deadlines");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
exportDeadlines.setRemoveOnCancelPolicy(true);
|
||||
Semaphore capacity = new Semaphore(properties.getMaxQueueSize());
|
||||
LogRecordExporter measured = new LogRecordExporter() {
|
||||
@Override
|
||||
public CompletableResultCode export(Collection<LogRecordData> records) {
|
||||
int count = records.size();
|
||||
// Own the completion boundary: an SDK async callback can throw without completing
|
||||
// its result (e.g. 1.31 reading a truncated error body). The first outcome wins;
|
||||
// late completion cannot release permits or update counters a second time.
|
||||
CompletableResultCode bounded = new CompletableResultCode();
|
||||
pendingExports.add(bounded);
|
||||
bounded.whenComplete(() -> {
|
||||
pendingExports.remove(bounded);
|
||||
capacity.release(count);
|
||||
if (bounded.isSuccess()) { metrics.exported.increment(count); }
|
||||
else { metrics.exportFailures.increment(); metrics.warn(); }
|
||||
});
|
||||
try {
|
||||
var deadline = exportDeadlines.schedule(bounded::fail,
|
||||
properties.exportBudget().toNanos(), TimeUnit.NANOSECONDS);
|
||||
bounded.whenComplete(() -> deadline.cancel(false));
|
||||
CompletableResultCode result = exporter.export(records);
|
||||
result.whenComplete(() -> {
|
||||
if (result.isSuccess()) { bounded.succeed(); }
|
||||
else { bounded.fail(); }
|
||||
});
|
||||
} catch (RuntimeException ex) {
|
||||
bounded.fail();
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
@Override public CompletableResultCode flush() { return exporter.flush(); }
|
||||
@Override public CompletableResultCode shutdown() { return exporter.shutdown(); }
|
||||
};
|
||||
BatchLogRecordProcessor batch = BatchLogRecordProcessor.builder(measured)
|
||||
.setMeterProvider(MeterProvider.noop())
|
||||
.setMaxQueueSize(properties.getMaxQueueSize())
|
||||
.setMaxExportBatchSize(properties.getMaxExportBatchSize())
|
||||
.setScheduleDelay(properties.getScheduleDelay())
|
||||
.setExporterTimeout(properties.exportBudget()).build();
|
||||
LogRecordProcessor admission = new LogRecordProcessor() {
|
||||
@Override
|
||||
public void onEmit(Context context, ReadWriteLogRecord record) {
|
||||
if (!accepting.get()) { metrics.closed.increment(); return; }
|
||||
if (!capacity.tryAcquire()) { metrics.queueFull.increment(); return; }
|
||||
try {
|
||||
batch.onEmit(context, record);
|
||||
metrics.enqueued.increment();
|
||||
} catch (RuntimeException ex) {
|
||||
capacity.release();
|
||||
metrics.error();
|
||||
}
|
||||
}
|
||||
@Override public CompletableResultCode forceFlush() { return batch.forceFlush(); }
|
||||
@Override public CompletableResultCode shutdown() { return batch.shutdown(); }
|
||||
};
|
||||
provider = SdkLoggerProvider.builder().setResource(resource)
|
||||
.setLogLimits(() -> LogLimits.builder().setMaxNumberOfAttributes(16)
|
||||
.setMaxAttributeValueLength(256).build())
|
||||
.addLogRecordProcessor(admission).build();
|
||||
logger = provider.loggerBuilder("com.iflytek.skillhub.business").setInstrumentationVersion("1").build();
|
||||
}
|
||||
|
||||
void emit(BusinessEventMapper.MappedRecord record, Context context) {
|
||||
try {
|
||||
if (!accepting.get()) { metrics.closed.increment(); return; }
|
||||
logger.logRecordBuilder().setTimestamp(record.timestamp()).setContext(context)
|
||||
.setAllAttributes(record.attributes()).emit();
|
||||
} catch (RuntimeException ex) {
|
||||
metrics.error();
|
||||
}
|
||||
}
|
||||
|
||||
CompletableResultCode forceFlush() { return provider.forceFlush(); }
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!accepting.getAndSet(false)) { return; }
|
||||
try {
|
||||
CompletableResultCode shutdown = provider.shutdown();
|
||||
shutdown.join(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!shutdown.isDone()) { exporter.shutdown(); }
|
||||
} catch (RuntimeException ex) {
|
||||
metrics.error();
|
||||
// Also cancel network work if shutdown itself unexpectedly fails.
|
||||
try { exporter.shutdown(); } catch (RuntimeException shutdownException) { metrics.error(); }
|
||||
} finally {
|
||||
// No deadline thread/tasks survive this lifecycle. A concurrent late export either
|
||||
// appears here or is rejected by the stopped scheduler and fails its own guard.
|
||||
exportDeadlines.shutdownNow();
|
||||
pendingExports.forEach(CompletableResultCode::fail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Startup-only operator settings. Deliberately no toString: headers contain secrets. */
|
||||
@ConfigurationProperties("skillhub.observability.business-logs")
|
||||
public class BusinessLogsProperties {
|
||||
private boolean enabled;
|
||||
private String endpoint = "";
|
||||
private String headers = "";
|
||||
private Duration timeout = Duration.ofSeconds(3);
|
||||
private String compression = "gzip";
|
||||
private int maxQueueSize = 2048;
|
||||
private int maxExportBatchSize = 256;
|
||||
private Duration scheduleDelay = Duration.ofSeconds(1);
|
||||
private Duration shutdownTimeout = Duration.ofSeconds(5);
|
||||
private int retryMaxAttempts = 3;
|
||||
private Duration retryInitialBackoff = Duration.ofMillis(200);
|
||||
private Duration retryMaxBackoff = Duration.ofSeconds(1);
|
||||
private Set<String> eventAllowlist = BusinessEventMapper.EVENT_NAMES;
|
||||
private Set<Long> namespaceAllowlist = Set.of();
|
||||
|
||||
public void validate() {
|
||||
if (!enabled) { return; }
|
||||
try {
|
||||
URI uri = URI.create(endpoint);
|
||||
String host = uri.getHost();
|
||||
if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))
|
||||
|| host == null || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
|
||||
|| uri.getPath() == null || uri.getPath().isBlank() || "/".equals(uri.getPath())
|
||||
|| host.startsWith("169.254.") || host.equals("100.100.100.200")
|
||||
|| host.equalsIgnoreCase("metadata.google.internal") || host.equalsIgnoreCase("metadata")
|
||||
|| host.equalsIgnoreCase("[fd00:ec2::254]") || host.equals("0.0.0.0")) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
throw new IllegalStateException("skillhub.observability.business-logs.endpoint requires an explicit HTTP(S) Logs URL without credentials, query, fragment or metadata target");
|
||||
}
|
||||
require(maxQueueSize >= 1 && maxQueueSize <= 65536, "max-queue-size must be 1..65536");
|
||||
require(maxExportBatchSize >= 1 && maxExportBatchSize <= Math.min(4096, maxQueueSize),
|
||||
"max-export-batch-size must be 1..min(4096,max-queue-size)");
|
||||
duration(timeout, 100, 30000, "timeout");
|
||||
duration(scheduleDelay, 10, 60000, "schedule-delay");
|
||||
duration(shutdownTimeout, 100, 30000, "shutdown-timeout");
|
||||
duration(retryInitialBackoff, 1, 5000, "retry-initial-backoff");
|
||||
duration(retryMaxBackoff, 1, 10000, "retry-max-backoff");
|
||||
require(retryMaxAttempts >= 1 && retryMaxAttempts <= 5, "retry-max-attempts must be 1..5");
|
||||
require(retryInitialBackoff.compareTo(retryMaxBackoff) <= 0, "retry backoff bounds are inconsistent");
|
||||
require(Set.of("none", "gzip").contains(compression), "compression must be none or gzip");
|
||||
require(eventAllowlist != null && BusinessEventMapper.EVENT_NAMES.containsAll(eventAllowlist), "event-allowlist contains unsupported event names");
|
||||
require(namespaceAllowlist != null && namespaceAllowlist.size() <= 1000
|
||||
&& namespaceAllowlist.stream().allMatch(id -> id != null && id > 0), "namespace-allowlist requires at most 1000 positive IDs");
|
||||
parsedHeaders();
|
||||
}
|
||||
|
||||
Map<String, String> parsedHeaders() {
|
||||
try {
|
||||
if (headers == null || headers.isBlank()) { return Map.of(); }
|
||||
if (headers.length() > 8192) { throw new IllegalArgumentException(); }
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
String[] pairs = headers.split(",", -1);
|
||||
if (pairs.length > 16) { throw new IllegalArgumentException(); }
|
||||
for (String header : pairs) {
|
||||
String[] pair = header.split("=", 2);
|
||||
if (pair.length != 2 || result.size() >= 16) { throw new IllegalArgumentException(); }
|
||||
String name = URLDecoder.decode(pair[0].trim(), StandardCharsets.UTF_8);
|
||||
String value = URLDecoder.decode(pair[1].trim(), StandardCharsets.UTF_8);
|
||||
if (!name.matches("[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}")
|
||||
|| value.chars().anyMatch(c -> c < 32 || c > 126)) { throw new IllegalArgumentException(); }
|
||||
result.put(name, value);
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
} catch (RuntimeException ex) {
|
||||
throw new IllegalStateException("business-logs.headers must contain at most 16 URL-encoded name=value pairs (8192 characters), without control characters");
|
||||
}
|
||||
}
|
||||
|
||||
private static void duration(Duration value, long min, long max, String key) {
|
||||
require(value != null && value.compareTo(Duration.ofMillis(min)) >= 0
|
||||
&& value.compareTo(Duration.ofMillis(max)) <= 0, key + " is outside documented bounds");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) { throw new IllegalStateException("skillhub.observability.business-logs: " + message); }
|
||||
}
|
||||
|
||||
Duration exportBudget() {
|
||||
return timeout.plus(retryMaxBackoff).multipliedBy(retryMaxAttempts).plusSeconds(1);
|
||||
}
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public String getHeaders() { return headers; }
|
||||
public void setHeaders(String headers) { this.headers = headers; }
|
||||
public Duration getTimeout() { return timeout; }
|
||||
public void setTimeout(Duration timeout) { this.timeout = timeout; }
|
||||
public String getCompression() { return compression; }
|
||||
public void setCompression(String compression) { this.compression = compression; }
|
||||
public int getMaxQueueSize() { return maxQueueSize; }
|
||||
public void setMaxQueueSize(int maxQueueSize) { this.maxQueueSize = maxQueueSize; }
|
||||
public int getMaxExportBatchSize() { return maxExportBatchSize; }
|
||||
public void setMaxExportBatchSize(int maxExportBatchSize) { this.maxExportBatchSize = maxExportBatchSize; }
|
||||
public Duration getScheduleDelay() { return scheduleDelay; }
|
||||
public void setScheduleDelay(Duration scheduleDelay) { this.scheduleDelay = scheduleDelay; }
|
||||
public Duration getShutdownTimeout() { return shutdownTimeout; }
|
||||
public void setShutdownTimeout(Duration shutdownTimeout) { this.shutdownTimeout = shutdownTimeout; }
|
||||
public int getRetryMaxAttempts() { return retryMaxAttempts; }
|
||||
public void setRetryMaxAttempts(int retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; }
|
||||
public Duration getRetryInitialBackoff() { return retryInitialBackoff; }
|
||||
public void setRetryInitialBackoff(Duration retryInitialBackoff) { this.retryInitialBackoff = retryInitialBackoff; }
|
||||
public Duration getRetryMaxBackoff() { return retryMaxBackoff; }
|
||||
public void setRetryMaxBackoff(Duration retryMaxBackoff) { this.retryMaxBackoff = retryMaxBackoff; }
|
||||
public Set<String> getEventAllowlist() { return eventAllowlist; }
|
||||
public void setEventAllowlist(Set<String> eventAllowlist) { this.eventAllowlist = eventAllowlist; }
|
||||
public Set<Long> getNamespaceAllowlist() { return namespaceAllowlist; }
|
||||
public void setNamespaceAllowlist(Set<Long> namespaceAllowlist) { this.namespaceAllowlist = namespaceAllowlist; }
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Opt-in, independent, lossy business analytics adapter; never an audit or application-log bridge. */
|
||||
package com.iflytek.skillhub.observability.business;
|
||||
|
|
@ -170,6 +170,22 @@ skillhub:
|
|||
confirmation-enabled: ${SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED:false}
|
||||
observability:
|
||||
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
|
||||
business-logs:
|
||||
enabled: ${SKILLHUB_BUSINESS_LOGS_ENABLED:false}
|
||||
# Logs-specific URL is used as-is; include /v1/logs (not /v1/traces).
|
||||
endpoint: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:}
|
||||
headers: ${SKILLHUB_BUSINESS_LOGS_HEADERS:}
|
||||
timeout: ${SKILLHUB_BUSINESS_LOGS_TIMEOUT:3s}
|
||||
compression: ${SKILLHUB_BUSINESS_LOGS_COMPRESSION:gzip}
|
||||
max-queue-size: ${SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE:2048}
|
||||
max-export-batch-size: ${SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE:256}
|
||||
schedule-delay: ${SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY:1s}
|
||||
shutdown-timeout: ${SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT:5s}
|
||||
retry-max-attempts: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS:3}
|
||||
retry-initial-backoff: ${SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF:200ms}
|
||||
retry-max-backoff: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF:1s}
|
||||
event-allowlist: ${SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST:skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked}
|
||||
namespace-allowlist: ${SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST:}
|
||||
log-format: ${SKILLHUB_LOG_FORMAT:text}
|
||||
log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024}
|
||||
service-version: ${SKILLHUB_SERVICE_VERSION:unknown}
|
||||
|
|
@ -341,7 +357,7 @@ management:
|
|||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
include: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:health,info}
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@
|
|||
<appender-ref ref="json-console"/>
|
||||
</appender>
|
||||
|
||||
<!-- Business Logs only: raw SDK diagnostics may echo receiver bodies / transport exceptions
|
||||
containing credentials. Drop them on the business exporter's own threads; Trace and other
|
||||
exporters keep their existing diagnostics. sdk.logs is used only by the business pipeline. -->
|
||||
<turboFilter class="com.iflytek.skillhub.observability.business.BusinessLogsDiagnosticsFilter"/>
|
||||
<logger name="io.opentelemetry.sdk.logs" level="OFF"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="${activeAppender}"/>
|
||||
</root>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package com.iflytek.skillhub.metrics;
|
||||
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability
|
||||
@SpringBootTest(properties = "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus")
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class BusinessMetricsSecurityTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
@Autowired MeterRegistry registry;
|
||||
@MockBean NamespaceMemberRepository namespaceMemberRepository;
|
||||
@MockBean DeviceAuthService deviceAuthService;
|
||||
|
||||
static RequestPostProcessor session(String... roles) {
|
||||
var principal = new PlatformPrincipal("synthetic-metrics-user", "Metrics test", null, null, "local", Set.of(roles));
|
||||
var authorities = java.util.Arrays.stream(roles).map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList();
|
||||
return authentication(new UsernamePasswordAuthenticationToken(principal, null, authorities));
|
||||
}
|
||||
|
||||
@Test
|
||||
void optInExposesOnlyMetricsToAuthorizedSessions() throws Exception {
|
||||
registry.counter("skillhub.business.logs.enqueued").increment();
|
||||
for (String path : List.of("/actuator/metrics", "/actuator/metrics/skillhub.business.logs.enqueued", "/actuator/prometheus")) {
|
||||
mockMvc.perform(get(path).with(session("SUPER_ADMIN"))).andExpect(status().isOk());
|
||||
mockMvc.perform(get(path).with(session())).andExpect(status().isForbidden());
|
||||
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
|
||||
}
|
||||
mockMvc.perform(get("/actuator/env").with(session("SUPER_ADMIN"))).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,14 @@ class PrometheusSecurityTest {
|
|||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void metricsRemainUnexposedByDefaultEvenForAdmin() throws Exception {
|
||||
for (String path : java.util.List.of("/actuator/metrics", "/actuator/prometheus")) {
|
||||
mockMvc.perform(get(path).with(BusinessMetricsSecurityTest.session("SUPER_ADMIN")))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prometheusEndpointShouldNotBeAnonymous() throws Exception {
|
||||
mockMvc.perform(get("/actuator/prometheus"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.*;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BusinessEventMapperTest {
|
||||
private final BusinessEventMapper mapper = new BusinessEventMapper(new BusinessLogsProperties());
|
||||
private final BusinessEventSnapshot snapshot = new BusinessEventSnapshot(
|
||||
7L, SkillVisibility.PRIVATE, "reviewer-opaque", "user", "review", Instant.parse("2026-10-01T00:00:00Z"));
|
||||
|
||||
@Test
|
||||
void rejectionExportsOnlyExplicitAllowlistNeverReasonOrSubmitter() {
|
||||
var record = mapper.map(new ReviewRejectedEvent(3L, 1L, 2L, "reviewer-opaque", "secret-email",
|
||||
"token=secret signed-url IP UA content", snapshot)).orElseThrow();
|
||||
assertThat(record.timestamp()).isEqualTo(snapshot.occurredAt());
|
||||
assertThat(record.attributes().asMap().keySet()).extracting(AttributeKey::getKey).containsExactlyInAnyOrder(
|
||||
"skillhub.event.name", "skillhub.skill.id", "skillhub.version.id", "skillhub.namespace.id",
|
||||
"skillhub.visibility", "skillhub.source", "skillhub.actor.type", "user.id", "log.record.uid",
|
||||
"skillhub.review.id");
|
||||
assertThat(record.attributes().toString()).doesNotContain("secret", "signed-url", "content", "submitter");
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isEqualTo("reviewer-opaque");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousHasNoUserIdAndMappingHasStableUid() {
|
||||
var anonymous = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, null, "user", "download", snapshot.occurredAt());
|
||||
var record = mapper.map(new SkillDownloadedEvent(1L, 2L, anonymous)).orElseThrow();
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull();
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("skillhub.actor.type"))).isEqualTo("user");
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("log.record.uid")))
|
||||
.isEqualTo(record.uid()).matches("[0-9a-f-]{36}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAndEventAllowlistFailClosedAndMissingSnapshotIsNotEnrichedFromDatabase() {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setNamespaceAllowlist(Set.of(8L));
|
||||
assertThat(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "actor", snapshot))).isEmpty();
|
||||
properties.setNamespaceAllowlist(Set.of());
|
||||
properties.setEventAllowlist(Set.of("skill.download.prepared"));
|
||||
assertThat(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "actor", snapshot))).isEmpty();
|
||||
assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "actor"))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void userValuesAreLengthBoundedAndSystemDoesNotExportServiceAccountAsHuman() {
|
||||
var system = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "x".repeat(1000), "system", "auto_publish", snapshot.occurredAt());
|
||||
var record = mapper.map(new SkillPublishedEvent(1L, 2L, "ignored", system)).orElseThrow();
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull();
|
||||
var human = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "x".repeat(1000), "user", "review", snapshot.occurredAt());
|
||||
assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "ignored", human)).orElseThrow()
|
||||
.attributes().get(AttributeKey.stringKey("user.id"))).hasSize(256);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownEventAndNullContextFieldsAreNeverSerializedOrEnriched() {
|
||||
record SensitiveEvent(Long skillId, BusinessEventSnapshot snapshot, String password) implements SkillBusinessEvent {}
|
||||
assertThat(mapper.map(new SensitiveEvent(1L, snapshot, "must-never-appear"))).isEmpty();
|
||||
var missingSource = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "actor", "user", null, snapshot.occurredAt());
|
||||
assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "actor", missingSource))).isEmpty();
|
||||
assertThat(mapper.map(new SkillVersionYankedEvent(1L, 2L, "actor", false, snapshot))).isEmpty();
|
||||
assertThat(mapper.map(new SkillStatusChangedEvent(1L, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE,
|
||||
com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE, snapshot))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservedBuiltInPublisherIsSystemEvenThroughOrdinaryPrivilegedPublishPath() {
|
||||
var skill = new com.iflytek.skillhub.domain.skill.Skill(7L, "not-exported", "builtin-skill-publisher", SkillVisibility.PUBLIC);
|
||||
var captured = BusinessEventSnapshot.capture(skill, "builtin-skill-publisher", "user", "auto_publish");
|
||||
assertThat(captured.actorType()).isEqualTo("system");
|
||||
var record = mapper.map(new SkillPublishedEvent(1L, 2L, "builtin-skill-publisher", captured)).orElseThrow();
|
||||
assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.event.*;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
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.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.MountableFile;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Actual production configuration sends protobuf over HTTP to a pinned real Collector/file sink. */
|
||||
@Testcontainers
|
||||
@org.junit.jupiter.api.Tag("process-global-otel-context")
|
||||
class BusinessLogsCollectorTest {
|
||||
@Container
|
||||
static final GenericContainer<?> COLLECTOR = new GenericContainer<>("otel/opentelemetry-collector-contrib:0.111.0")
|
||||
.withExposedPorts(4318)
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(Path.of("../../docs/examples/business-event-logs/collector.yaml").toAbsolutePath()),
|
||||
"/etc/otelcol-contrib/config.yaml")
|
||||
.withCreateContainerCmdModifier(cmd -> {
|
||||
cmd.withName("skillhub-1009-collector-integration");
|
||||
cmd.withUser("0:0");
|
||||
cmd.withVolumes(new com.github.dockerjava.api.model.Volume("/data"));
|
||||
cmd.getHostConfig().withMemory(128L * 1024 * 1024).withNanoCPUs(500_000_000L);
|
||||
})
|
||||
.waitingFor(Wait.forLogMessage(".*Everything is ready.*", 1));
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableAutoConfiguration
|
||||
@Import({SkillHubTracingConfiguration.class, BusinessLogsConfiguration.class})
|
||||
static class Application {}
|
||||
|
||||
@Test
|
||||
void realCollectorReceivesNineFactsInAllThreeTraceModesAndResourceLimitsAreExplicit() throws Exception {
|
||||
var hostConfig = COLLECTOR.getCurrentContainerInfo().getHostConfig();
|
||||
assertThat(hostConfig.getMemory()).isEqualTo(128L * 1024 * 1024);
|
||||
assertThat(hostConfig.getNanoCPUs()).isEqualTo(500_000_000L);
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "reviewer", "user", "review", Instant.now());
|
||||
var events = List.<SkillBusinessEvent>of(
|
||||
new SkillPublishedEvent(1L, 2L, "reviewer", snapshot),
|
||||
new SkillDownloadedEvent(1L, 2L, new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, null, "user", "download", snapshot.occurredAt())),
|
||||
new ReviewApprovedEvent(3L, 1L, 2L, "reviewer", "private-owner", snapshot),
|
||||
new ReviewRejectedEvent(3L, 1L, 2L, "reviewer", "private-owner", "SECRET-CONTENT-URL-TOKEN", snapshot),
|
||||
new PromotionApprovedEvent(4L, 1L, "reviewer", "private-owner", snapshot),
|
||||
new PromotionRejectedEvent(4L, 1L, "reviewer", "private-owner", "SECRET-CONTENT-URL-TOKEN", snapshot),
|
||||
new SkillStatusChangedEvent(1L, SkillStatus.ACTIVE, SkillStatus.ARCHIVED, snapshot),
|
||||
new SkillVisibilityChangedEvent(1L, SkillVisibility.PUBLIC, SkillVisibility.PRIVATE, snapshot),
|
||||
new SkillVersionYankedEvent(1L, 2L, "reviewer", true, snapshot));
|
||||
for (String mode : List.of("none", "otel-sdk", "external-agent")) {
|
||||
new ApplicationContextRunner().withUserConfiguration(Application.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=none",
|
||||
"skillhub.observability.tracing-mode=" + mode,
|
||||
"management.tracing.sampling.probability=0",
|
||||
"skillhub.observability.service-version=collector-test",
|
||||
"skillhub.observability.service-environment=test",
|
||||
"skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=http://" + COLLECTOR.getHost() + ":" + COLLECTOR.getMappedPort(4318) + "/v1/logs")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
events.forEach(context::publishEvent);
|
||||
var result = context.getBean(BusinessLogsPipeline.class).forceFlush();
|
||||
result.join(10, TimeUnit.SECONDS);
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var registry = context.getBean(SimpleMeterRegistry.class);
|
||||
assertThat(registry.get("skillhub.business.logs.exported").counter().count()).isEqualTo(9);
|
||||
});
|
||||
}
|
||||
String raw = awaitFile(27);
|
||||
assertThat(raw).doesNotContain("SECRET-CONTENT-URL-TOKEN", "private-owner", "request.id", "authorization");
|
||||
List<JsonNode> records = records(raw);
|
||||
assertThat(records).hasSize(27);
|
||||
var names = new HashSet<String>();
|
||||
var uids = new HashSet<String>();
|
||||
for (JsonNode record : records) {
|
||||
var attrs = attributes(record);
|
||||
names.add(attrs.get("skillhub.event.name").asText());
|
||||
assertThat(uids.add(attrs.get("log.record.uid").asText())).isTrue();
|
||||
assertThat(attrs.size()).isLessThanOrEqualTo(16);
|
||||
assertThat(attrs.get("skillhub.namespace.id").asText()).isEqualTo("7");
|
||||
if (attrs.get("skillhub.event.name").asText().equals("skill.download.prepared")) { assertThat(attrs).doesNotContainKey("user.id"); }
|
||||
}
|
||||
assertThat(names).containsExactlyInAnyOrderElementsOf(BusinessEventMapper.EVENT_NAMES);
|
||||
String evidence = System.getProperty("skillhub.test.evidence-dir");
|
||||
if (evidence != null) { Files.writeString(Path.of(evidence, "collector-received.jsonl"), raw); }
|
||||
System.out.println("BUSINESS_LOGS real Collector 0.111.0: queried 27 records / 9 event names / 27 unique uids; modes none,otel-sdk(sample=0),external-agent; 128 MiB, 0.5 CPU");
|
||||
String endpoint = "http://" + COLLECTOR.getHost() + ":" + COLLECTOR.getMappedPort(4318) + "/v1/logs";
|
||||
// A real stop/start must not truncate the local trial's records. Wait for
|
||||
// the restarted HTTP pipeline before reading, rather than racing file open.
|
||||
int previousPort = COLLECTOR.getMappedPort(4318);
|
||||
COLLECTOR.getDockerClient().stopContainerCmd(COLLECTOR.getContainerId()).exec();
|
||||
COLLECTOR.getDockerClient().startContainerCmd(COLLECTOR.getContainerId()).exec();
|
||||
// Docker may allocate another dynamic host port on restart. Inspect the
|
||||
// actual binding rather than using GenericContainer's cached initial info.
|
||||
String restartedPort = COLLECTOR.getDockerClient().inspectContainerCmd(COLLECTOR.getContainerId()).exec()
|
||||
.getNetworkSettings().getPorts().getBindings()
|
||||
.get(com.github.dockerjava.api.model.ExposedPort.tcp(4318))[0].getHostPortSpec();
|
||||
endpoint = "http://" + COLLECTOR.getHost() + ":" + restartedPort + "/v1/logs";
|
||||
System.out.printf("BUSINESS_LOGS Collector restart mapped port: %d -> %s%n", previousPort, restartedPort);
|
||||
var client = java.net.http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)).build();
|
||||
var probe = java.net.http.HttpRequest.newBuilder(java.net.URI.create(endpoint))
|
||||
.timeout(Duration.ofSeconds(1)).header("Content-Type", "application/x-protobuf")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.noBody()).build();
|
||||
try {
|
||||
org.awaitility.Awaitility.await().atMost(Duration.ofSeconds(10)).ignoreExceptions().untilAsserted(() ->
|
||||
assertThat(client.send(probe, java.net.http.HttpResponse.BodyHandlers.discarding()).statusCode()).isEqualTo(200));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
assertThat(records(awaitFile(27)).stream()
|
||||
.map(record -> attributes(record).get("log.record.uid").asText())
|
||||
.collect(java.util.stream.Collectors.toSet())).isEqualTo(uids);
|
||||
System.out.println("BUSINESS_LOGS real Collector stop/start: all 27 original record UIDs retained");
|
||||
COLLECTOR.getDockerClient().pauseContainerCmd(COLLECTOR.getContainerId()).exec();
|
||||
try {
|
||||
outageRunner(endpoint).run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < 1000; i++) { context.publishEvent(events.getFirst()); }
|
||||
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
assertThat(elapsed).isLessThan(1000);
|
||||
context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS);
|
||||
var registry = context.getBean(SimpleMeterRegistry.class);
|
||||
double dropped = registry.get("skillhub.business.logs.dropped").tag("reason", "queue_full").counter().count();
|
||||
assertThat(dropped).isGreaterThanOrEqualTo(998);
|
||||
assertThat(registry.get("skillhub.business.logs.export.failures").counter().count()).isPositive();
|
||||
System.out.printf("BUSINESS_LOGS paused real Collector: 1000 caller events in %d ms, drops=%.0f; timeout failures counted%n", elapsed, dropped);
|
||||
});
|
||||
} finally { COLLECTOR.getDockerClient().unpauseContainerCmd(COLLECTOR.getContainerId()).exec(); }
|
||||
COLLECTOR.stop();
|
||||
outageRunner(endpoint).run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
long start = System.nanoTime();
|
||||
context.publishEvent(events.getFirst());
|
||||
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
assertThat(elapsed).isLessThan(1000);
|
||||
context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(context.getBean(SimpleMeterRegistry.class).get("skillhub.business.logs.export.failures").counter().count()).isEqualTo(1);
|
||||
System.out.printf("BUSINESS_LOGS stopped real Collector: caller=%d ms; failure counted, no business exception%n", elapsed);
|
||||
});
|
||||
}
|
||||
|
||||
private static ApplicationContextRunner outageRunner(String endpoint) {
|
||||
return new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=" + endpoint,
|
||||
"skillhub.observability.business-logs.max-queue-size=2",
|
||||
"skillhub.observability.business-logs.max-export-batch-size=1",
|
||||
"skillhub.observability.business-logs.timeout=500ms",
|
||||
"skillhub.observability.business-logs.retry-max-attempts=1");
|
||||
}
|
||||
|
||||
private static String awaitFile(int expected) throws Exception {
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos();
|
||||
String raw = "";
|
||||
Exception lastError = null;
|
||||
do {
|
||||
try {
|
||||
raw = COLLECTOR.copyFileFromContainer("/data/business-logs.json", stream -> new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
if (records(raw).size() >= expected) { return raw; }
|
||||
} catch (Exception ex) { lastError = ex; }
|
||||
TimeUnit.MILLISECONDS.sleep(100);
|
||||
} while (System.nanoTime() < deadline);
|
||||
String evidence = System.getProperty("skillhub.test.evidence-dir");
|
||||
if (evidence != null) {
|
||||
Files.writeString(Path.of(evidence, "collector-debug.jsonl"), raw);
|
||||
Files.writeString(Path.of(evidence, "collector-debug.log"), COLLECTOR.getLogs());
|
||||
}
|
||||
throw new AssertionError("Collector file did not contain " + expected + " records; raw characters=" + raw.length(), lastError);
|
||||
}
|
||||
|
||||
static List<JsonNode> records(String raw) throws Exception {
|
||||
List<JsonNode> result = new ArrayList<>();
|
||||
var mapper = new ObjectMapper();
|
||||
for (String line : raw.lines().filter(s -> !s.isBlank()).toList()) {
|
||||
JsonNode root = mapper.readTree(line);
|
||||
for (JsonNode resource : root.path("resourceLogs")) {
|
||||
for (JsonNode scope : resource.path("scopeLogs")) {
|
||||
for (JsonNode record : scope.path("logRecords")) { result.add(record); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Map<String, JsonNode> attributes(JsonNode record) {
|
||||
var result = new java.util.HashMap<String, JsonNode>();
|
||||
for (JsonNode attr : record.path("attributes")) { result.put(attr.path("key").asText(), attr.path("value").elements().next()); }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class BusinessLogsConfigurationTest {
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withUserConfiguration(BusinessLogsConfiguration.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new);
|
||||
|
||||
@Test
|
||||
void defaultOffCreatesNoProviderPipelineOrListener() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).doesNotHaveBean(BusinessLogsPipeline.class);
|
||||
assertThat(context).doesNotHaveBean(BusinessEventListener.class);
|
||||
assertThat(context).doesNotHaveBean(io.opentelemetry.sdk.logs.SdkLoggerProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledRequiresExplicitEndpoint() {
|
||||
runner.withPropertyValues("skillhub.observability.business-logs.enabled=true")
|
||||
.run(context -> assertThat(context).hasFailed());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"http://169.254.169.254/v1/logs", "http://metadata.google.internal/v1/logs",
|
||||
"http://user:password@localhost/v1/logs", "http://localhost/v1/logs?secret=x", "file:///v1/logs"})
|
||||
void invalidDestinationsFailWithoutEchoingInput(String endpoint) {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setEndpoint(endpoint);
|
||||
assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageNotContaining(endpoint).hasMessageNotContaining("password").hasMessageNotContaining("secret=x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void budgetsAndEventNamesAreValidated() {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setEndpoint("http://localhost:4318/v1/logs");
|
||||
properties.setMaxQueueSize(1);
|
||||
properties.setMaxExportBatchSize(2);
|
||||
assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class);
|
||||
properties.setMaxExportBatchSize(1);
|
||||
properties.setEventAllowlist(java.util.Set.of("not-a-business-event"));
|
||||
assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerEncodingIsSecretFriendlyAndErrorsNeverEchoSecretInput() {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setHeaders("Authorization=Bearer%20fake%2Bsecret,x-tenant=example");
|
||||
assertThat(properties.parsedHeaders()).containsEntry("Authorization", "Bearer fake+secret");
|
||||
properties.setHeaders("Authorization=fake-secret%0Ainjected");
|
||||
assertThatThrownBy(properties::parsedHeaders).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageNotContaining("fake-secret").hasMessageNotContaining("injected");
|
||||
properties.setHeaders(String.join(",", java.util.Collections.nCopies(17, "same=value")));
|
||||
assertThatThrownBy(properties::parsedHeaders).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyNamespaceFilterAndExplicitEmptyEventFilterBindWithoutDefaultDestination() {
|
||||
runner.withPropertyValues("skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=http://127.0.0.1:4318/v1/logs",
|
||||
"skillhub.observability.business-logs.namespace-allowlist=",
|
||||
"skillhub.observability.business-logs.event-allowlist=")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBean(BusinessLogsProperties.class).getNamespaceAllowlist()).isEmpty();
|
||||
assertThat(context.getBean(BusinessLogsProperties.class).getEventAllowlist()).isEmpty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.logging.LoggingInitializationContext;
|
||||
import org.springframework.boot.logging.LoggingSystem;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Load the shipped logging config, including JUL-to-SLF4J routing used by Boot.
|
||||
* Surefire runs this tag in a dedicated, non-reused JVM: Boot logging/JUL are
|
||||
* process-global, and raw SDK spans initialize OTel ContextStorage before Boot
|
||||
* can install its MDC wrapper. Neither can be restored by LoggingSystem.cleanUp().
|
||||
*/
|
||||
@org.junit.jupiter.api.Tag("process-global-diagnostics")
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class BusinessLogsDiagnosticsTest {
|
||||
private static final String CREDENTIAL = "SYNTHETIC-1009-CREDENTIAL";
|
||||
private LoggingSystem logging;
|
||||
|
||||
private void configureShippedLogging(String format) {
|
||||
logging = LoggingSystem.get(getClass().getClassLoader());
|
||||
logging.beforeInitialize();
|
||||
var environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("diagnostic-test",
|
||||
java.util.Map.of("skillhub.observability.log-format", format)));
|
||||
logging.initialize(new LoggingInitializationContext(environment), "classpath:logback-spring.xml", null);
|
||||
String processor = "io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor";
|
||||
org.slf4j.LoggerFactory.getLogger(processor);
|
||||
assertThat(logging.getLoggerConfiguration(processor).getEffectiveLevel())
|
||||
.isEqualTo(org.springframework.boot.logging.LogLevel.OFF);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"text", "json"})
|
||||
void businessLogsDisabledKeepsExistingTraceExportFailureDiagnostics(String format, CapturedOutput output)
|
||||
throws Exception {
|
||||
configureShippedLogging(format);
|
||||
try (var receiver = new UnauthorizedReceiver("/v1/traces")) {
|
||||
exportSpanFailure(receiver.endpoint());
|
||||
assertThat(receiver.requests.get()).isEqualTo(1);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll())
|
||||
.contains("Failed to export spans", "401", "trace-receiver-rejected"));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"text", "json"})
|
||||
void businessLogsEnabledKeepsTraceDiagnosticsAndHidesBusinessReceiverResponse(String format,
|
||||
CapturedOutput output) throws Exception {
|
||||
configureShippedLogging(format);
|
||||
try (var traces = new UnauthorizedReceiver("/v1/traces");
|
||||
var logs = new UnauthorizedReceiver("/v1/logs")) {
|
||||
exportFailure(logs.endpoint());
|
||||
exportSpanFailure(traces.endpoint());
|
||||
assertThat(logs.requests.get()).isEqualTo(1);
|
||||
assertThat(traces.requests.get()).isEqualTo(1);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll())
|
||||
.contains("Business Logs telemetry failed", "Failed to export spans"));
|
||||
assertThat(output.getAll()).doesNotContain(CREDENTIAL, "Failed to export logs");
|
||||
}
|
||||
}
|
||||
|
||||
private static void exportSpanFailure(String endpoint) {
|
||||
var exporter = io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter.builder()
|
||||
.setEndpoint(endpoint).setTimeout(java.time.Duration.ofSeconds(2)).build();
|
||||
var provider = io.opentelemetry.sdk.trace.SdkTracerProvider.builder()
|
||||
.addSpanProcessor(io.opentelemetry.sdk.trace.export.SimpleSpanProcessor.create(exporter)).build();
|
||||
try {
|
||||
provider.get("trace-diagnostics-test").spanBuilder("trace-401").startSpan().end();
|
||||
assertThat(provider.forceFlush().join(5, TimeUnit.SECONDS).isSuccess()).isFalse();
|
||||
} finally { provider.shutdown().join(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
|
||||
/** Receiver that rejects with 401 and echoes a marker (the synthetic credential for /v1/logs). */
|
||||
private static final class UnauthorizedReceiver implements AutoCloseable {
|
||||
private final HttpServer server;
|
||||
private final String path;
|
||||
final java.util.concurrent.atomic.AtomicInteger requests = new java.util.concurrent.atomic.AtomicInteger();
|
||||
|
||||
UnauthorizedReceiver(String path) throws java.io.IOException {
|
||||
this.path = path;
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext(path, exchange -> {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
requests.incrementAndGet();
|
||||
String echoed = path.endsWith("/logs") ? CREDENTIAL : "trace-receiver-rejected";
|
||||
byte[] text = ("auth failed for Bearer " + echoed).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] body = new byte[text.length + 4];
|
||||
body[0] = 0x08; body[1] = 0x10; body[2] = 0x12; body[3] = (byte) text.length;
|
||||
System.arraycopy(text, 0, body, 4, text.length);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf");
|
||||
exchange.sendResponseHeaders(401, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
String endpoint() { return "http://127.0.0.1:" + server.getAddress().getPort() + path; }
|
||||
|
||||
@Override public void close() { server.stop(0); }
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanupLogging() {
|
||||
if (logging != null) {
|
||||
logging.cleanUp();
|
||||
logging.initialize(new LoggingInitializationContext(new StandardEnvironment()), "classpath:logback-test.xml", null);
|
||||
logging.cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"text", "json"})
|
||||
void receiverStatusEchoingAuthorizationCannotLeakToAnyConsoleLog(String format, CapturedOutput output) throws Exception {
|
||||
configureShippedLogging(format);
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/v1/logs", exchange -> {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
String message = "auth failed for " + exchange.getRequestHeaders().getFirst("Authorization");
|
||||
byte[] text = message.getBytes(StandardCharsets.UTF_8);
|
||||
// google.rpc.Status { code: 16, message: <echoed transport header> }
|
||||
byte[] body = new byte[text.length + 4];
|
||||
body[0] = 0x08; body[1] = 0x10; body[2] = 0x12; body[3] = (byte) text.length;
|
||||
System.arraycopy(text, 0, body, 4, text.length);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf");
|
||||
exchange.sendResponseHeaders(401, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
exportFailure("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs");
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed"));
|
||||
assertThat(output.getAll()).contains("Business Logs telemetry failed")
|
||||
.doesNotContain(CREDENTIAL, "auth failed for", "Failed to export logs");
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"text", "json"})
|
||||
void transportExceptionEchoingSensitiveTextCannotLeakToAnyConsoleLog(String format, CapturedOutput output) throws Exception {
|
||||
configureShippedLogging(format);
|
||||
// OkHttp reports an invalid status line verbatim in ProtocolException (transport onError).
|
||||
try (var receiver = new BusinessLogsTestReceiver(
|
||||
"HTTP/1.1 " + CREDENTIAL + "\r\nConnection: close\r\n\r\n")) {
|
||||
exportFailure(receiver.endpoint());
|
||||
assertThat(receiver.requests.get()).isEqualTo(1);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed"));
|
||||
assertThat(output.getAll()).contains("Business Logs telemetry failed")
|
||||
.doesNotContain(CREDENTIAL, "Unexpected status line", "Failed to export logs");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"text", "json"})
|
||||
void malformedChunkedUnauthorizedCannotPrintUncaughtExceptionAndLaterExportRecovers(
|
||||
String format, CapturedOutput output) throws Exception {
|
||||
configureShippedLogging(format);
|
||||
try (var receiver = new BusinessLogsTestReceiver(
|
||||
"HTTP/1.1 401 Unauthorized\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
|
||||
+ "1 Bearer " + CREDENTIAL + "\r\nx\r\n0\r\n\r\n")) {
|
||||
var previousHandler = Thread.getDefaultUncaughtExceptionHandler();
|
||||
var ownedGroups = new java.util.HashSet<ThreadGroup>();
|
||||
new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=" + receiver.endpoint(),
|
||||
"skillhub.observability.business-logs.headers=Authorization=Bearer%20" + CREDENTIAL,
|
||||
"skillhub.observability.business-logs.retry-max-attempts=1",
|
||||
"skillhub.observability.business-logs.timeout=100ms",
|
||||
"skillhub.observability.business-logs.retry-initial-backoff=100ms",
|
||||
"skillhub.observability.business-logs.retry-max-backoff=100ms",
|
||||
"skillhub.observability.business-logs.max-queue-size=1",
|
||||
"skillhub.observability.business-logs.max-export-batch-size=1")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
var event = new SkillPublishedEvent(1L, 2L, "reviewer", snapshot);
|
||||
var pipeline = context.getBean(BusinessLogsPipeline.class);
|
||||
var metrics = context.getBean(BusinessLogsMetrics.class);
|
||||
context.publishEvent(event);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1));
|
||||
context.publishEvent(event);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(metrics.exported.count()).isEqualTo(1));
|
||||
assertThat(metrics.enqueued.count()).isEqualTo(2);
|
||||
assertThat(metrics.queueFull.count()).isZero();
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
assertThat(receiver.requests.get()).isEqualTo(2);
|
||||
assertThat(metrics.errors.count()).isEqualTo(1);
|
||||
Thread.getAllStackTraces().keySet().stream()
|
||||
.filter(thread -> thread.getName().startsWith("okhttp-dispatch-"))
|
||||
.map(Thread::getThreadGroup)
|
||||
.filter(group -> group instanceof BusinessLogsExporterBoundary).forEach(ownedGroups::add);
|
||||
assertThat(ownedGroups).hasSize(1);
|
||||
assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(previousHandler);
|
||||
});
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(Thread.getAllStackTraces().keySet())
|
||||
.noneMatch(thread -> ownedGroups.contains(thread.getThreadGroup()) && thread.getName().startsWith("okhttp-dispatch-")));
|
||||
assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(previousHandler);
|
||||
org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed"));
|
||||
// OutputCapture covers System.out/System.err, including the shipped text/async JSON appenders.
|
||||
assertThat(output.getOut()).doesNotContain(CREDENTIAL, "Exception in thread", "ProtocolException", "IllegalStateException");
|
||||
assertThat(output.getErr()).doesNotContain(CREDENTIAL, "Exception in thread", "ProtocolException", "IllegalStateException");
|
||||
}
|
||||
}
|
||||
|
||||
private static void exportFailure(String endpoint) {
|
||||
new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=" + endpoint,
|
||||
"skillhub.observability.business-logs.headers=Authorization=Bearer%20" + CREDENTIAL,
|
||||
"skillhub.observability.business-logs.retry-max-attempts=1",
|
||||
"skillhub.observability.business-logs.timeout=500ms")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
context.publishEvent(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot));
|
||||
context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS);
|
||||
var metrics = context.getBean(BusinessLogsMetrics.class);
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
assertThat(metrics.exported.count()).isZero();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class BusinessLogsExporterBoundaryTest {
|
||||
@Test
|
||||
void onlyOwnedDispatchersAreContainedAndPreviousDefaultHandlerIsUnchanged() throws Exception {
|
||||
var previous = Thread.getDefaultUncaughtExceptionHandler();
|
||||
var handled = new CopyOnWriteArrayList<Thread>();
|
||||
var failures = new CopyOnWriteArrayList<Throwable>();
|
||||
Thread.UncaughtExceptionHandler sentinel = (thread, failure) -> { handled.add(thread); failures.add(failure); };
|
||||
Thread.setDefaultUncaughtExceptionHandler(sentinel);
|
||||
try {
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
ThreadGroup group = BusinessLogsExporterBoundary.build(() -> Thread.currentThread().getThreadGroup(), metrics);
|
||||
var failure = new IllegalStateException("SYNTHETIC-1009-UNCAUGHT-PLACEHOLDER");
|
||||
Thread owned = new Thread(group, () -> { throw failure; }, "okhttp-dispatch-1");
|
||||
// Same dispatcher name outside the private group must NOT be swallowed.
|
||||
Thread unrelated = new Thread(() -> { throw failure; }, "okhttp-dispatch-1");
|
||||
// Even inside the private group, other thread kinds retain normal default handling.
|
||||
Thread otherKind = new Thread(group, () -> { throw failure; }, "unrelated-worker");
|
||||
for (Thread thread : java.util.List.of(owned, unrelated, otherKind)) {
|
||||
thread.start(); thread.join(5000); assertThat(thread.isAlive()).isFalse();
|
||||
}
|
||||
assertThat(handled).containsExactly(unrelated, otherKind);
|
||||
assertThat(failures).containsExactly(failure, failure);
|
||||
assertThat(metrics.errors.count()).isEqualTo(1);
|
||||
assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(sentinel);
|
||||
} finally { Thread.setDefaultUncaughtExceptionHandler(previous); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructionFailureIsFixedTextWithoutRawCauseAndInterruptionIsRestored() {
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertThatThrownBy(() -> BusinessLogsExporterBoundary.build(() -> {
|
||||
throw new IllegalArgumentException("SYNTHETIC-1009-CONSTRUCTION-PLACEHOLDER");
|
||||
}, metrics)).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("Business Logs exporter initialization failed").hasNoCause();
|
||||
assertThat(Thread.currentThread().isInterrupted()).isTrue();
|
||||
} finally { Thread.interrupted(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
|
||||
import io.opentelemetry.sdk.common.export.RetryPolicy;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@org.junit.jupiter.api.Tag("process-global-otel-context")
|
||||
class BusinessLogsHttpTest {
|
||||
@Test
|
||||
void truncatedErrorBodyCannotPermanentlyExhaustAdmission() throws Exception {
|
||||
try (var receiver = new BusinessLogsTestReceiver(
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Length: 100\r\nConnection: close\r\n\r\nx")) {
|
||||
new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=" + receiver.endpoint(),
|
||||
"skillhub.observability.business-logs.max-queue-size=1",
|
||||
"skillhub.observability.business-logs.max-export-batch-size=1",
|
||||
"skillhub.observability.business-logs.timeout=100ms",
|
||||
"skillhub.observability.business-logs.retry-max-attempts=1",
|
||||
"skillhub.observability.business-logs.retry-max-backoff=1ms",
|
||||
"skillhub.observability.business-logs.retry-initial-backoff=1ms")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
var pipeline = context.getBean(BusinessLogsPipeline.class);
|
||||
var metrics = context.getBean(BusinessLogsMetrics.class);
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
var event = new SkillPublishedEvent(1L, 2L, "reviewer", snapshot);
|
||||
context.publishEvent(event);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
// Wait beyond the bounded export budget, not just the HTTP timeout.
|
||||
org.awaitility.Awaitility.await().atMost(Duration.ofSeconds(5))
|
||||
.untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1));
|
||||
for (int i = 0; i < 2; i++) {
|
||||
context.publishEvent(event);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertThat(receiver.requests.get()).isEqualTo(3);
|
||||
assertThat(metrics.enqueued.count()).isEqualTo(3);
|
||||
assertThat(metrics.exported.count()).isEqualTo(2);
|
||||
assertThat(metrics.queueFull.count()).isZero();
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundedRetryReusesIdenticalUidAndPayloadAndAuthenticationIsTransportOnly() throws Exception {
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
List<byte[]> bodies = new CopyOnWriteArrayList<>();
|
||||
List<String> auth = new CopyOnWriteArrayList<>();
|
||||
var requests = new AtomicInteger();
|
||||
server.createContext("/v1/logs", exchange -> {
|
||||
bodies.add(exchange.getRequestBody().readAllBytes());
|
||||
auth.add(exchange.getRequestHeaders().getFirst("Authorization"));
|
||||
exchange.sendResponseHeaders(requests.incrementAndGet() == 1 ? 503 : 200, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setCompression("none");
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var exporter = OtlpHttpLogRecordExporter.builder()
|
||||
.setEndpoint("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs")
|
||||
.setTimeout(Duration.ofMillis(500)).addHeader("Authorization", "Bearer fake-deployment-secret")
|
||||
.setRetryPolicy(RetryPolicy.builder().setMaxAttempts(2).setInitialBackoff(Duration.ofMillis(10))
|
||||
.setMaxBackoff(Duration.ofMillis(10)).build()).build();
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
var record = new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow();
|
||||
pipeline.emit(record, Context.root());
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(requests.get()).isEqualTo(2);
|
||||
assertThat(bodies.get(0)).isEqualTo(bodies.get(1));
|
||||
String payload = new String(bodies.getFirst(), java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
assertThat(payload).contains(record.uid()).doesNotContain("fake-deployment-secret", "Authorization");
|
||||
assertThat(auth).containsExactly("Bearer fake-deployment-secret", "Bearer fake-deployment-secret");
|
||||
assertThat(metrics.exported.count()).isEqualTo(1);
|
||||
assertThat(metrics.exportFailures.count()).isZero();
|
||||
System.out.println("BUSINESS_LOGS retry: 503 then 200, 2 identical payloads, 1 stable uid; transport-only auth");
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoHundredPartialSuccessIsCountedAsSdkSuccessWithoutWholeBatchReplay() throws Exception {
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
var requests = new AtomicInteger();
|
||||
server.createContext("/v1/logs", exchange -> {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
requests.incrementAndGet();
|
||||
// ExportLogsServiceResponse.partial_success { rejected_log_records: 1 }
|
||||
byte[] body = {0x0a, 0x02, 0x08, 0x01};
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var properties = new BusinessLogsProperties();
|
||||
var exporter = OtlpHttpLogRecordExporter.builder()
|
||||
.setEndpoint("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs").build();
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
pipeline.emit(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow(), Context.root());
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(requests.get()).isEqualTo(1);
|
||||
assertThat(metrics.exported.count()).isEqualTo(1);
|
||||
System.out.println("BUSINESS_LOGS partial-success: 200 rejected=1 body, SDK success=1, requests=1 (known 1.31 limitation)");
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.sdk.common.CompletableResultCode;
|
||||
import io.opentelemetry.sdk.logs.data.LogRecordData;
|
||||
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@org.junit.jupiter.api.Tag("process-global-otel-context")
|
||||
class BusinessLogsPipelineTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void lateCompletionAfterDeadlineCannotReleaseTwiceOrChangeOutcome(boolean lateSuccess) throws Exception {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setMaxQueueSize(1);
|
||||
properties.setMaxExportBatchSize(1);
|
||||
properties.setTimeout(Duration.ofMillis(100));
|
||||
properties.setRetryMaxAttempts(1);
|
||||
properties.setRetryMaxBackoff(Duration.ofMillis(1));
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var exports = new LinkedBlockingQueue<CompletableResultCode>();
|
||||
var exporter = new LogRecordExporter() {
|
||||
@Override public CompletableResultCode export(Collection<LogRecordData> records) {
|
||||
var result = new CompletableResultCode();
|
||||
exports.add(result);
|
||||
return result;
|
||||
}
|
||||
@Override public CompletableResultCode flush() { return CompletableResultCode.ofSuccess(); }
|
||||
@Override public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); }
|
||||
};
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now());
|
||||
var record = new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow();
|
||||
pipeline.emit(record, Context.root());
|
||||
var abandoned = exports.poll(5, TimeUnit.SECONDS);
|
||||
assertThat(abandoned).isNotNull();
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1));
|
||||
pipeline.emit(record, Context.root());
|
||||
var recovering = exports.poll(5, TimeUnit.SECONDS);
|
||||
assertThat(recovering).isNotNull();
|
||||
if (lateSuccess) { abandoned.succeed(); } else { abandoned.fail(); }
|
||||
// The recovered permit is now occupied again. Late completion must not add capacity.
|
||||
pipeline.emit(record, Context.root());
|
||||
assertThat(metrics.enqueued.count()).isEqualTo(2);
|
||||
assertThat(metrics.queueFull.count()).isEqualTo(1);
|
||||
assertThat(metrics.exported.count()).isZero();
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
recovering.succeed();
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(metrics.exported.count()).isEqualTo(1);
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillBusinessEvent;
|
||||
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.domain.review.*;
|
||||
import com.iflytek.skillhub.domain.security.*;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.*;
|
||||
import com.iflytek.skillhub.domain.skill.service.*;
|
||||
import com.iflytek.skillhub.domain.skill.validation.*;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.infra.jpa.*;
|
||||
import com.iflytek.skillhub.storage.ObjectMetadata;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import io.opentelemetry.sdk.logs.data.LogRecordData;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Real service proxies, JPA repositories, schema migrations, commit/rollback and analytics adapter. */
|
||||
@DataJpaTest(showSql = false)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Testcontainers
|
||||
@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
@Import({SkillOwnerAccessPolicy.class, JpaSkillRepositoryAdapter.class, SkillPublishService.class,
|
||||
SkillReviewSubmitService.class, SkillGovernanceService.class, SkillVisibilityService.class,
|
||||
SkillDownloadService.class, SkillSlugResolutionService.class, ReviewService.class, PromotionService.class,
|
||||
ReviewPermissionChecker.class, BusinessLogsProducerPostgresTest.Configuration.class})
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@org.junit.jupiter.api.Tag("process-global-otel-context")
|
||||
class BusinessLogsProducerPostgresTest {
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withTmpFs(Map.of("/var/lib/postgresql/data", "rw"))
|
||||
.withCreateContainerCmdModifier(cmd -> cmd.withName("skillhub-1009-producers-postgres"));
|
||||
|
||||
@DynamicPropertySource
|
||||
static void database(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
|
||||
registry.add("spring.flyway.enabled", () -> true);
|
||||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class Configuration {
|
||||
@Bean Clock clock() { return Clock.systemUTC(); }
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
@Bean VisibilityChecker visibilityChecker() { return new VisibilityChecker(); }
|
||||
@Bean BusinessLogsTransactionTest.CapturingExporter exporter() { return new BusinessLogsTransactionTest.CapturingExporter(); }
|
||||
@Bean BusinessLogsMetrics metrics() { return new BusinessLogsMetrics(new SimpleMeterRegistry()); }
|
||||
@Bean(destroyMethod = "close") BusinessLogsPipeline pipeline(BusinessLogsTransactionTest.CapturingExporter exporter, BusinessLogsMetrics metrics) {
|
||||
return new BusinessLogsPipeline(new BusinessLogsProperties(), Resource.empty(), exporter, metrics);
|
||||
}
|
||||
@Bean BusinessEventListener listener(BusinessLogsPipeline pipeline, BusinessLogsMetrics metrics) {
|
||||
return new BusinessEventListener(new BusinessEventMapper(new BusinessLogsProperties()), pipeline, metrics);
|
||||
}
|
||||
@Bean PromotionFilterProbe promotionFilterProbe() { return new PromotionFilterProbe(); }
|
||||
}
|
||||
|
||||
static class PromotionFilterProbe {
|
||||
private BusinessEventListener delegate;
|
||||
@org.springframework.context.event.EventListener
|
||||
public void onEvent(SkillBusinessEvent event) {
|
||||
if (delegate != null) { delegate.onEvent(event); }
|
||||
}
|
||||
}
|
||||
|
||||
@MockBean SecurityScanService scanner;
|
||||
@MockBean GovernanceNotificationService notifications;
|
||||
@MockBean ReviewAvatarParticipant avatars;
|
||||
@MockBean ObjectStorageService storage;
|
||||
@MockBean SkillStorageDeletionCompensationService compensation;
|
||||
@MockBean AuditLogService audit;
|
||||
@MockBean SkillPackageValidator validator;
|
||||
@MockBean SkillMetadataParser parser;
|
||||
@MockBean PrePublishValidator prePublish;
|
||||
@Autowired SkillPublishService publishing;
|
||||
@Autowired SkillReviewSubmitService confirmations;
|
||||
@Autowired ReviewService reviewing;
|
||||
@Autowired PromotionService promoting;
|
||||
@Autowired SkillGovernanceService governance;
|
||||
@Autowired SkillVisibilityService visibility;
|
||||
@Autowired SkillDownloadService downloads;
|
||||
@Autowired SkillJpaRepository skills;
|
||||
@Autowired SkillVersionJpaRepository versions;
|
||||
@Autowired ReviewTaskJpaRepository reviews;
|
||||
@Autowired PromotionRequestJpaRepository promotions;
|
||||
@Autowired NamespaceJpaRepository namespaces;
|
||||
@Autowired NamespaceMemberJpaRepository members;
|
||||
@Autowired UserAccountJpaRepository users;
|
||||
@Autowired SecurityAuditJpaRepository audits;
|
||||
@Autowired PlatformTransactionManager transactions;
|
||||
@Autowired BusinessLogsPipeline pipeline;
|
||||
@Autowired BusinessLogsTransactionTest.CapturingExporter exporter;
|
||||
@Autowired PromotionFilterProbe filterProbe;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
exporter.records.clear();
|
||||
when(scanner.isEnabled()).thenReturn(true);
|
||||
when(validator.validate(anyList())).thenReturn(ValidationResult.pass());
|
||||
when(prePublish.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(parser.parse(anyString())).thenReturn(new SkillMetadata("business-probe", "Never export title", "3.0.0", "Never export body", Map.of()));
|
||||
when(storage.exists(anyString())).thenReturn(true);
|
||||
when(storage.getMetadata(anyString())).thenReturn(new ObjectMetadata(12, "application/zip", Instant.now()));
|
||||
when(storage.generatePresignedUrl(anyString(), any(), anyString())).thenReturn("https://private.invalid/?signed=secret");
|
||||
}
|
||||
|
||||
static Stream<Arguments> paths() {
|
||||
return Stream.of("auto", "private", "review-approved", "review-rejected", "promotion-initial",
|
||||
"promotion-update", "promotion-rejected", "archive", "unarchive", "visibility-narrow",
|
||||
"visibility-expanded", "yank", "download").flatMap(path -> Stream.of(Arguments.of(path, false), Arguments.of(path, true)));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}, rollback={1}")
|
||||
@MethodSource("paths")
|
||||
void producerFactIsExactlyDocumentedAndOnlyAfterRealCommit(String path, boolean rollback) {
|
||||
Fixture fixture = fixture(path);
|
||||
List<String> expected = expected(path);
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
switch (path) {
|
||||
case "auto" -> publishing.publishFromEntries(fixture.namespaceSlug(),
|
||||
List.of(new PackageEntry("SKILL.md", "content".getBytes(java.nio.charset.StandardCharsets.UTF_8), 7, "text/markdown")),
|
||||
fixture.owner(), SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"));
|
||||
case "private" -> confirmations.confirmPublish(fixture.skillId(), fixture.uploadedId(), fixture.owner(), Map.of());
|
||||
case "review-approved" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of("SUPER_ADMIN"));
|
||||
case "review-rejected" -> reviewing.rejectReview(fixture.reviewId(), fixture.reviewer(), "private reason", Map.of(), Set.of("SUPER_ADMIN"));
|
||||
case "promotion-initial", "promotion-update" -> promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of("SUPER_ADMIN"), Set.of());
|
||||
case "promotion-rejected" -> promoting.rejectPromotion(fixture.promotionId(), fixture.reviewer(), "private reason", Set.of("SUPER_ADMIN"));
|
||||
case "archive" -> governance.archiveSkill(fixture.skillId(), fixture.owner(), Map.of(), "not-exported-ip", "not-exported-ua", "private reason");
|
||||
case "unarchive" -> governance.unarchiveSkill(fixture.skillId(), fixture.owner(), Map.of(), "not-exported-ip", "not-exported-ua");
|
||||
case "visibility-narrow" -> visibility.change(fixture.skillId(), SkillVisibility.PRIVATE, fixture.owner(), Map.of(), Set.of());
|
||||
case "visibility-expanded" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of("SUPER_ADMIN"));
|
||||
case "yank" -> governance.yankVersion(fixture.publishedId(), fixture.reviewer(), "not-exported-ip", "not-exported-ua", "private reason");
|
||||
case "download" -> assertThat(downloads.downloadVersion(fixture.namespaceSlug(), "business-probe", "1.0.0", null, Map.of()).presignedUrl()).contains("signed=secret");
|
||||
default -> throw new AssertionError(path);
|
||||
}
|
||||
assertThat(exporter.records).isEmpty();
|
||||
if (rollback) { status.setRollbackOnly(); }
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).extracting(r -> r.getAttributes().get(AttributeKey.stringKey("skillhub.event.name")))
|
||||
.containsExactlyInAnyOrderElementsOf(rollback ? List.of() : expected);
|
||||
if (!rollback) {
|
||||
for (var record : exporter.records) {
|
||||
assertExportedIdentity(path, fixture, record);
|
||||
assertThat(record.getAttributes().toString()).doesNotContain("private reason", "not-exported", "secret", "business-probe", "Never export");
|
||||
String actor = record.getAttributes().get(AttributeKey.stringKey("user.id"));
|
||||
if (path.equals("download")) { assertThat(actor).isNull(); }
|
||||
else if (path.startsWith("review") || path.startsWith("promotion") || path.equals("visibility-expanded") || path.equals("yank")) {
|
||||
assertThat(actor).isEqualTo(fixture.reviewer());
|
||||
} else { assertThat(actor).isEqualTo(fixture.owner()); }
|
||||
}
|
||||
}
|
||||
// These two representative paths additionally prove persistent rollback state;
|
||||
// all matrix rows above prove listener suppression, not every database mutation.
|
||||
if (path.equals("private")) {
|
||||
assertThat(((SkillVersionRepository) versions).findById(fixture.uploadedId()).orElseThrow().getStatus())
|
||||
.isEqualTo(rollback ? SkillVersionStatus.UPLOADED : SkillVersionStatus.PUBLISHED);
|
||||
}
|
||||
if (path.equals("review-approved")) {
|
||||
assertThat(((ReviewTaskRepository) reviews).findById(fixture.reviewId()).orElseThrow().getStatus())
|
||||
.isEqualTo(rollback ? ReviewTaskStatus.PENDING : ReviewTaskStatus.APPROVED);
|
||||
}
|
||||
System.out.printf("BUSINESS_LOGS producer=%s rollback=%s received=%d expected=%d%n", path, rollback, exporter.records.size(), rollback ? 0 : expected.size());
|
||||
}
|
||||
|
||||
private void assertExportedIdentity(String path, Fixture fixture, LogRecordData record) {
|
||||
var attributes = record.getAttributes();
|
||||
String name = attributes.get(AttributeKey.stringKey("skillhub.event.name"));
|
||||
Long skillId = fixture.skillId();
|
||||
Long namespaceId = ((SkillRepository) skills).findById(skillId).orElseThrow().getNamespaceId();
|
||||
Long versionId = switch (path) {
|
||||
case "auto" -> versions.findBySkillIdAndVersion(skillId, "3.0.0").orElseThrow().getId();
|
||||
case "private", "review-approved", "review-rejected" -> fixture.uploadedId();
|
||||
case "download", "yank" -> fixture.publishedId();
|
||||
default -> null;
|
||||
};
|
||||
if (path.startsWith("promotion") && name.equals("skill.publication.effective")) {
|
||||
PromotionRequest request = ((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow();
|
||||
skillId = request.getTargetSkillId();
|
||||
versionId = request.getTargetVersionId();
|
||||
assertThat(skillId).isNotNull().isNotEqualTo(fixture.skillId());
|
||||
assertThat(versionId).isNotNull().isNotEqualTo(fixture.publishedId());
|
||||
assertThat(request.getTargetNamespaceId()).isNotEqualTo(namespaceId);
|
||||
namespaceId = request.getTargetNamespaceId();
|
||||
assertThat(((SkillRepository) skills).findById(skillId).orElseThrow().getNamespaceId()).isEqualTo(namespaceId);
|
||||
assertThat(((SkillVersionRepository) versions).findById(versionId).orElseThrow().getSkillId()).isEqualTo(skillId);
|
||||
}
|
||||
assertThat(attributes.get(AttributeKey.longKey("skillhub.skill.id"))).as("%s skill", name).isEqualTo(skillId);
|
||||
assertThat(attributes.get(AttributeKey.longKey("skillhub.namespace.id"))).as("%s namespace", name).isEqualTo(namespaceId);
|
||||
assertThat(attributes.get(AttributeKey.longKey("skillhub.version.id"))).as("%s version (or absent)", name).isEqualTo(versionId);
|
||||
assertThat(attributes.get(AttributeKey.longKey("skillhub.review.id")))
|
||||
.isEqualTo(name.startsWith("skill.review.") ? fixture.reviewId() : null);
|
||||
assertThat(attributes.get(AttributeKey.longKey("skillhub.promotion.id")))
|
||||
.isEqualTo(name.startsWith("skill.promotion.") ? fixture.promotionId() : null);
|
||||
String source = switch (path) {
|
||||
case "auto" -> "auto_publish";
|
||||
case "private" -> "private_confirm";
|
||||
case "review-approved", "review-rejected" -> "review";
|
||||
case "promotion-initial", "promotion-update", "promotion-rejected" -> "promotion";
|
||||
case "archive", "unarchive", "yank" -> "governance";
|
||||
case "visibility-narrow", "visibility-expanded" -> "visibility";
|
||||
case "download" -> "download";
|
||||
default -> throw new AssertionError(path);
|
||||
};
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.source"))).isEqualTo(source);
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility")))
|
||||
.isEqualTo(path.equals("private") || path.equals("visibility-narrow") ? "PRIVATE" : "PUBLIC");
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.status.old")))
|
||||
.isEqualTo(path.equals("archive") ? "ACTIVE" : path.equals("unarchive") ? "ARCHIVED" : null);
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.status.new")))
|
||||
.isEqualTo(path.equals("archive") ? "ARCHIVED" : path.equals("unarchive") ? "ACTIVE" : null);
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility.old")))
|
||||
.isEqualTo(path.equals("visibility-narrow") ? "PUBLIC" : path.equals("visibility-expanded") ? "PRIVATE" : null);
|
||||
assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility.new")))
|
||||
.isEqualTo(path.equals("visibility-narrow") ? "PRIVATE" : path.equals("visibility-expanded") ? "PUBLIC" : null);
|
||||
}
|
||||
|
||||
static Stream<Arguments> promotionFilters() {
|
||||
return Stream.of("promotion-initial", "promotion-update")
|
||||
.flatMap(path -> Stream.of(Arguments.of(path, true), Arguments.of(path, false)));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}, sourceOnly={1}")
|
||||
@MethodSource("promotionFilters")
|
||||
void promotionNamespaceFilterRetainsOnlySourceDecisionOrTargetPublication(String path, boolean sourceOnly) {
|
||||
Fixture fixture = fixture(path);
|
||||
PromotionRequest request = ((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow();
|
||||
Long sourceNamespace = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow().getNamespaceId();
|
||||
assertThat(request.getTargetNamespaceId()).isNotEqualTo(sourceNamespace);
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setNamespaceAllowlist(Set.of(sourceOnly ? sourceNamespace : request.getTargetNamespaceId()));
|
||||
var captured = new BusinessLogsTransactionTest.CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
try (var filtered = new BusinessLogsPipeline(properties, Resource.empty(), captured, metrics)) {
|
||||
filterProbe.delegate = new BusinessEventListener(new BusinessEventMapper(properties), filtered, metrics);
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of("SUPER_ADMIN"), Set.of());
|
||||
assertThat(captured.records).isEmpty();
|
||||
});
|
||||
filtered.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(captured.records).singleElement().satisfies(record -> {
|
||||
assertThat(record.getAttributes().get(AttributeKey.stringKey("skillhub.event.name")))
|
||||
.isEqualTo(sourceOnly ? "skill.promotion.approved" : "skill.publication.effective");
|
||||
assertExportedIdentity(path, fixture, record);
|
||||
});
|
||||
} finally { filterProbe.delegate = null; }
|
||||
}
|
||||
|
||||
private List<String> expected(String path) {
|
||||
return switch (path) {
|
||||
case "auto", "private" -> List.of("skill.publication.effective");
|
||||
case "review-approved" -> List.of("skill.publication.effective", "skill.review.approved");
|
||||
case "review-rejected" -> List.of("skill.review.rejected");
|
||||
case "promotion-initial", "promotion-update" -> List.of("skill.publication.effective", "skill.promotion.approved");
|
||||
case "promotion-rejected" -> List.of("skill.promotion.rejected");
|
||||
case "archive", "unarchive" -> List.of("skill.status.changed");
|
||||
case "visibility-narrow", "visibility-expanded" -> List.of("skill.visibility.changed");
|
||||
case "yank" -> List.of("skill.version.yanked");
|
||||
case "download" -> List.of("skill.download.prepared");
|
||||
default -> throw new AssertionError(path);
|
||||
};
|
||||
}
|
||||
|
||||
static Stream<String> unauthorizedPaths() {
|
||||
return Stream.of("auto", "private", "review-approved", "review-rejected", "promotion-initial",
|
||||
"promotion-update", "promotion-rejected", "archive", "unarchive", "visibility-narrow",
|
||||
"visibility-expanded", "yank", "download");
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "unauthorized {0}")
|
||||
@MethodSource("unauthorizedPaths")
|
||||
void deniedProducerPathsPersistNoMutationAndEmitNoFact(String path) {
|
||||
Fixture fixture = fixture(path);
|
||||
if (path.equals("download")) {
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
Skill skill = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow();
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
skills.saveAndFlush(skill);
|
||||
});
|
||||
}
|
||||
Skill before = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> {
|
||||
switch (path) {
|
||||
case "auto" -> publishing.publishFromEntries(fixture.namespaceSlug(),
|
||||
List.of(new PackageEntry("SKILL.md", new byte[]{1}, 1, "text/markdown")),
|
||||
fixture.reviewer(), SkillVisibility.PUBLIC, Set.of());
|
||||
case "private" -> confirmations.confirmPublish(fixture.skillId(), fixture.uploadedId(), fixture.reviewer(), Map.of());
|
||||
case "review-approved", "visibility-expanded" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of());
|
||||
case "review-rejected" -> reviewing.rejectReview(fixture.reviewId(), fixture.reviewer(), "reason", Map.of(), Set.of());
|
||||
case "promotion-initial", "promotion-update" -> promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of(), Set.of());
|
||||
case "promotion-rejected" -> promoting.rejectPromotion(fixture.promotionId(), fixture.reviewer(), "reason", Set.of());
|
||||
case "archive" -> governance.archiveSkill(fixture.skillId(), fixture.reviewer(), Map.of(), null, null, "reason");
|
||||
case "unarchive" -> governance.unarchiveSkill(fixture.skillId(), fixture.reviewer(), Map.of(), null, null);
|
||||
case "visibility-narrow" -> visibility.change(fixture.skillId(), SkillVisibility.PRIVATE, fixture.reviewer(), Map.of(), Set.of());
|
||||
case "yank" -> governance.yankVersion(before, ((SkillVersionRepository) versions).findById(fixture.publishedId()).orElseThrow(),
|
||||
fixture.reviewer(), Map.of(), null, null, "reason");
|
||||
case "download" -> downloads.downloadVersion(fixture.namespaceSlug(), "business-probe", "1.0.0", null, Map.of());
|
||||
default -> throw new AssertionError(path);
|
||||
}
|
||||
}).isInstanceOfAny(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class,
|
||||
com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).isEmpty();
|
||||
Skill after = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow();
|
||||
assertThat(after.getStatus()).isEqualTo(before.getStatus());
|
||||
assertThat(after.getVisibility()).isEqualTo(before.getVisibility());
|
||||
assertThat(((SkillVersionRepository) versions).findById(fixture.publishedId()).orElseThrow().getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
|
||||
if (fixture.reviewId() != null) {
|
||||
assertThat(((ReviewTaskRepository) reviews).findById(fixture.reviewId()).orElseThrow().getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
|
||||
}
|
||||
if (fixture.promotionId() != null) {
|
||||
assertThat(((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow().getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
|
||||
}
|
||||
System.out.printf("BUSINESS_LOGS denied producer=%s received=0; persisted state unchanged%n", path);
|
||||
}
|
||||
|
||||
private Fixture fixture(String path) {
|
||||
return new TransactionTemplate(transactions).execute(status -> {
|
||||
String suffix = UUID.randomUUID().toString().substring(0, 8);
|
||||
String owner = "business-owner-" + suffix;
|
||||
String reviewer = "business-reviewer-" + suffix;
|
||||
users.saveAndFlush(new UserAccount(owner, owner, null, null));
|
||||
users.saveAndFlush(new UserAccount(reviewer, reviewer, null, null));
|
||||
Namespace namespace = namespaces.saveAndFlush(new Namespace("business-" + suffix, "Never export namespace", owner));
|
||||
members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER));
|
||||
SkillVisibility scope = path.equals("private") || path.equals("visibility-expanded") ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC;
|
||||
Skill skill = skills.saveAndFlush(new Skill(namespace.getId(), "business-probe", owner, scope));
|
||||
SkillVersion published = new SkillVersion(skill.getId(), "1.0.0", owner);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
published.setBundleReady(true);
|
||||
published.setDownloadReady(true);
|
||||
published.setPublishedAt(Instant.now());
|
||||
published = versions.saveAndFlush(published);
|
||||
skill.setLatestVersionId(published.getId());
|
||||
if (path.equals("unarchive")) { skill.setStatus(SkillStatus.ARCHIVED); }
|
||||
skills.saveAndFlush(skill);
|
||||
SkillVersion uploaded = new SkillVersion(skill.getId(), "2.0.0", owner);
|
||||
uploaded.setStatus(path.startsWith("review") ? SkillVersionStatus.PENDING_REVIEW : SkillVersionStatus.UPLOADED);
|
||||
uploaded = versions.saveAndFlush(uploaded);
|
||||
Long reviewId = null;
|
||||
if (path.startsWith("review")) {
|
||||
reviewId = reviews.saveAndFlush(new ReviewTask(uploaded.getId(), skill.getId(), namespace.getId(), uploaded.getVersion(), owner)).getId();
|
||||
}
|
||||
if (path.equals("visibility-expanded")) {
|
||||
SecurityAudit scan = new SecurityAudit(published.getId(), ScannerType.SKILL_SCANNER, "scan-" + suffix);
|
||||
scan.setIsSafe(true); scan.setScannedAt(Instant.now()); audits.saveAndFlush(scan);
|
||||
var task = ReviewTask.forVisibility(skill, SkillVisibility.PUBLIC, owner);
|
||||
task.setVisibilityScanTaskId(scan.getTaskId());
|
||||
reviewId = reviews.saveAndFlush(task).getId();
|
||||
}
|
||||
Long promotionId = null;
|
||||
if (path.startsWith("promotion")) {
|
||||
Namespace targetNamespace = new Namespace("global-" + suffix, "Never export global name", owner);
|
||||
targetNamespace.setType(NamespaceType.GLOBAL);
|
||||
targetNamespace = namespaces.saveAndFlush(targetNamespace);
|
||||
PromotionRequest request = new PromotionRequest(skill.getId(), published.getId(), targetNamespace.getId(), owner);
|
||||
if (path.equals("promotion-update")) {
|
||||
Skill target = new Skill(targetNamespace.getId(), skill.getSlug(), owner, SkillVisibility.PUBLIC);
|
||||
target.setSourceSkillId(skill.getId()); target = skills.saveAndFlush(target);
|
||||
SkillVersion old = new SkillVersion(target.getId(), "0.9.0", owner);
|
||||
old.setStatus(SkillVersionStatus.PUBLISHED); old = versions.saveAndFlush(old);
|
||||
target.setLatestVersionId(old.getId()); skills.saveAndFlush(target);
|
||||
PromotionRequest initial = new PromotionRequest(skill.getId(), published.getId(), targetNamespace.getId(), owner);
|
||||
initial.setStatus(ReviewTaskStatus.APPROVED); initial.setTargetSkillId(target.getId()); initial.setTargetVersionId(old.getId());
|
||||
promotions.saveAndFlush(initial);
|
||||
request.setRequestKind(PromotionRequestKind.UPDATE); request.setTargetSkillId(target.getId());
|
||||
}
|
||||
promotionId = promotions.saveAndFlush(request).getId();
|
||||
}
|
||||
return new Fixture(skill.getId(), published.getId(), uploaded.getId(), reviewId, promotionId, namespace.getSlug(), owner, reviewer);
|
||||
});
|
||||
}
|
||||
|
||||
private record Fixture(Long skillId, Long publishedId, Long uploadedId, Long reviewId, Long promotionId,
|
||||
String namespaceSlug, String owner, String reviewer) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Raw local HTTP peer for responses that HttpServer deliberately cannot produce. */
|
||||
final class BusinessLogsTestReceiver implements AutoCloseable {
|
||||
private final ServerSocket server;
|
||||
private final Thread worker;
|
||||
private final String firstResponse;
|
||||
final AtomicInteger requests = new AtomicInteger();
|
||||
private volatile Throwable failure;
|
||||
|
||||
BusinessLogsTestReceiver(String firstResponse) throws IOException {
|
||||
this.firstResponse = firstResponse;
|
||||
server = new ServerSocket(0, 10, InetAddress.getByName("127.0.0.1"));
|
||||
worker = new Thread(this::receive, "skillhub-1009-http-test");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
String endpoint() { return "http://127.0.0.1:" + server.getLocalPort() + "/v1/logs"; }
|
||||
|
||||
private void receive() {
|
||||
try {
|
||||
while (!server.isClosed()) {
|
||||
try (Socket socket = server.accept()) {
|
||||
socket.setSoTimeout(5000);
|
||||
var input = new BufferedInputStream(socket.getInputStream());
|
||||
int length = 0;
|
||||
String line;
|
||||
while (!(line = line(input)).isEmpty()) {
|
||||
if (line.toLowerCase(java.util.Locale.ROOT).startsWith("content-length:")) {
|
||||
length = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());
|
||||
}
|
||||
}
|
||||
if (input.readNBytes(length).length != length) { throw new IOException("Incomplete test request"); }
|
||||
int count = requests.incrementAndGet();
|
||||
String response = count == 1 ? firstResponse
|
||||
: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
socket.getOutputStream().write(response.getBytes(StandardCharsets.ISO_8859_1));
|
||||
socket.getOutputStream().flush();
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
if (!server.isClosed()) { failure = ex; }
|
||||
} catch (RuntimeException ex) { failure = ex; }
|
||||
}
|
||||
|
||||
private static String line(BufferedInputStream input) throws IOException {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
int value;
|
||||
while ((value = input.read()) != -1) {
|
||||
if (value == '\n') { return bytes.toString(StandardCharsets.ISO_8859_1).trim(); }
|
||||
bytes.write(value);
|
||||
}
|
||||
throw new IOException("Incomplete test headers");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
server.close();
|
||||
worker.join(6000);
|
||||
if (worker.isAlive()) { throw new AssertionError("HTTP test peer did not stop"); }
|
||||
if (failure != null) { throw new AssertionError("HTTP test peer failed", failure); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.micrometer.tracing.Tracer;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
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;
|
||||
|
||||
/** Production configurations must coexist in the same startup, before any raw test context. */
|
||||
@Tag("process-global-otel-context")
|
||||
class BusinessLogsTracingCoexistenceTest {
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableAutoConfiguration
|
||||
@Import({SkillHubTracingConfiguration.class, BusinessLogsConfiguration.class})
|
||||
static class Application {}
|
||||
|
||||
@Test
|
||||
void enablingBusinessLogsPreservesTraceAndSpanCorrelation() {
|
||||
new ApplicationContextRunner().withUserConfiguration(Application.class)
|
||||
.withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new)
|
||||
.withPropertyValues("spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=none",
|
||||
"skillhub.observability.tracing-mode=otel-sdk",
|
||||
"management.tracing.sampling.probability=1",
|
||||
"skillhub.observability.business-logs.enabled=true",
|
||||
"skillhub.observability.business-logs.endpoint=http://127.0.0.1:4318/v1/logs")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasSingleBean(BusinessLogsPipeline.class);
|
||||
Tracer tracer = context.getBean(Tracer.class);
|
||||
var span = tracer.nextSpan().name("business-logs-coexistence").start();
|
||||
try (var ignored = tracer.withSpan(span)) {
|
||||
assertThat(MDC.get("traceId")).isEqualTo(span.context().traceId()).hasSize(32);
|
||||
assertThat(MDC.get("spanId")).isEqualTo(span.context().spanId()).hasSize(16);
|
||||
} finally {
|
||||
span.end();
|
||||
MDC.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
package com.iflytek.skillhub.observability.business;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.*;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import com.iflytek.skillhub.storage.ObjectMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import io.opentelemetry.api.baggage.Baggage;
|
||||
import io.opentelemetry.api.trace.Span;
|
||||
import io.opentelemetry.api.trace.SpanContext;
|
||||
import io.opentelemetry.api.trace.TraceFlags;
|
||||
import io.opentelemetry.api.trace.TraceState;
|
||||
import io.opentelemetry.sdk.common.CompletableResultCode;
|
||||
import io.opentelemetry.sdk.logs.data.LogRecordData;
|
||||
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
|
||||
import io.opentelemetry.sdk.resources.Resource;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Testcontainers
|
||||
@org.junit.jupiter.api.Tag("process-global-otel-context")
|
||||
class BusinessLogsTransactionTest {
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withTmpFs(java.util.Map.of("/var/lib/postgresql/data", "rw"))
|
||||
.withCreateContainerCmdModifier(cmd -> cmd.withName("skillhub-1009-business-transactions"));
|
||||
|
||||
private final BusinessEventSnapshot snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE,
|
||||
"approver", "user", "review", Instant.parse("2026-10-01T00:00:00Z"));
|
||||
|
||||
@Test
|
||||
void realPostgresCommitEmitsAndRollbackDoesNotForEveryIncludedFact() {
|
||||
var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("create table if not exists analytics_transaction_probe (id int primary key)");
|
||||
var transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||
var exporter = new CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var properties = new BusinessLogsProperties();
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics);
|
||||
var context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(BusinessEventListener.class,
|
||||
() -> new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics));
|
||||
context.refresh();
|
||||
var events = List.<SkillBusinessEvent>of(
|
||||
new SkillPublishedEvent(1L, 2L, "approver", snapshot),
|
||||
new SkillDownloadedEvent(1L, 2L, snapshot),
|
||||
new ReviewApprovedEvent(3L, 1L, 2L, "approver", "owner", snapshot),
|
||||
new ReviewRejectedEvent(3L, 1L, 2L, "approver", "owner", "secret", snapshot),
|
||||
new PromotionApprovedEvent(4L, 1L, "approver", "owner", snapshot),
|
||||
new PromotionRejectedEvent(4L, 1L, "approver", "owner", "secret", snapshot),
|
||||
new SkillStatusChangedEvent(1L, SkillStatus.ACTIVE, SkillStatus.ARCHIVED, snapshot),
|
||||
new SkillVisibilityChangedEvent(1L, SkillVisibility.PUBLIC, SkillVisibility.PRIVATE, snapshot),
|
||||
new SkillVersionYankedEvent(1L, 2L, "approver", true, snapshot));
|
||||
transaction.executeWithoutResult(status -> {
|
||||
jdbc.update("insert into analytics_transaction_probe values (1) on conflict do nothing");
|
||||
events.forEach(context::publishEvent);
|
||||
assertThat(metrics.enqueued.count()).isZero();
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).hasSize(9);
|
||||
assertThat(exporter.records).extracting(r -> r.getAttributes().get(AttributeKey.stringKey("skillhub.event.name")))
|
||||
.containsExactlyInAnyOrderElementsOf(BusinessEventMapper.EVENT_NAMES);
|
||||
transaction.executeWithoutResult(status -> {
|
||||
jdbc.update("insert into analytics_transaction_probe values (2)");
|
||||
events.forEach(context::publishEvent);
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(jdbc.queryForObject("select count(*) from analytics_transaction_probe where id=2", Integer.class)).isZero();
|
||||
assertThat(exporter.records).hasSize(9);
|
||||
context.publishEvent(new SkillDownloadedEvent(1L, 2L, snapshot));
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).hasSize(10);
|
||||
assertThat(exporter.records.getLast().getTimestampEpochNanos()).isEqualTo(snapshot.occurredAt().getEpochSecond() * 1_000_000_000L);
|
||||
System.out.println("BUSINESS_LOGS postgres: 9 commit facts, 0 rollback facts, 1 nontransactional fact");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedSavepointRollbackIsExplicitlyOutsideSuppressionContract() {
|
||||
var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("create table nested_analytics_probe (id int primary key)");
|
||||
var manager = new DataSourceTransactionManager(dataSource);
|
||||
var outer = new TransactionTemplate(manager);
|
||||
var nested = new TransactionTemplate(manager);
|
||||
nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
|
||||
var exporter = new CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var properties = new BusinessLogsProperties();
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
outer.executeWithoutResult(status -> {
|
||||
nested.executeWithoutResult(savepoint -> {
|
||||
assertThat(savepoint.hasSavepoint()).isTrue();
|
||||
jdbc.update("insert into nested_analytics_probe values (1)");
|
||||
listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot));
|
||||
savepoint.setRollbackOnly();
|
||||
});
|
||||
assertThat(jdbc.queryForObject("select count(*) from nested_analytics_probe", Integer.class)).isZero();
|
||||
assertThat(metrics.enqueued.count()).isZero();
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(jdbc.queryForObject("select count(*) from nested_analytics_probe", Integer.class)).isZero();
|
||||
// Synchronization follows the physical transaction, not the rolled-back savepoint.
|
||||
assertThat(exporter.records).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresNewCommitExportsEvenWhenOuterTransactionRollsBack() {
|
||||
var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("create table requires_new_analytics_probe (id int primary key)");
|
||||
var manager = new DataSourceTransactionManager(dataSource);
|
||||
var outer = new TransactionTemplate(manager);
|
||||
var independent = new TransactionTemplate(manager);
|
||||
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
var exporter = new CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var properties = new BusinessLogsProperties();
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
outer.executeWithoutResult(status -> {
|
||||
jdbc.update("insert into requires_new_analytics_probe values (1)");
|
||||
listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot));
|
||||
independent.executeWithoutResult(inner -> {
|
||||
jdbc.update("insert into requires_new_analytics_probe values (2)");
|
||||
listener.onEvent(new SkillPublishedEvent(3L, 4L, "approver", snapshot));
|
||||
});
|
||||
assertThat(metrics.enqueued.count()).isEqualTo(1);
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(jdbc.queryForList("select id from requires_new_analytics_probe", Integer.class)).containsExactly(2);
|
||||
assertThat(exporter.records).hasSize(1);
|
||||
assertThat(exporter.records.getFirst().getAttributes().get(AttributeKey.longKey("skillhub.skill.id"))).isEqualTo(3L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void transactionDocumentationStatesSavepointExclusionAndIndependentCommit() throws Exception {
|
||||
for (String path : List.of("docs/business-event-logs.md",
|
||||
"openspec/changes/add-otlp-business-event-logs/design.md",
|
||||
"openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md",
|
||||
"openspec/changes/add-otlp-business-event-logs/acceptance.md")) {
|
||||
assertThat(java.nio.file.Files.readString(java.nio.file.Path.of("../..", path)))
|
||||
.as(path).contains("PROPAGATION_NESTED", "REQUIRES_NEW");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsampledSpanCapturedBeforeCommitAndNoMdcOrBaggageExported() {
|
||||
var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
var transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||
var exporter = new CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var properties = new BusinessLogsProperties();
|
||||
var spanContext = SpanContext.create("12345678901234567890123456789012", "1234567890123456", TraceFlags.getDefault(), TraceState.getDefault());
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
transaction.executeWithoutResult(status -> {
|
||||
try (var mdc = MDC.putCloseable("sensitive.mdc", "SYNTHETIC-1009-MDC-SECRET");
|
||||
var baggage = Baggage.builder().put("sensitive.baggage", "SYNTHETIC-1009-BAGGAGE-SECRET").build().makeCurrent();
|
||||
var ignored = Span.wrap(spanContext).makeCurrent()) {
|
||||
assertThat(MDC.get("sensitive.mdc")).isEqualTo("SYNTHETIC-1009-MDC-SECRET");
|
||||
assertThat(Baggage.current().getEntryValue("sensitive.baggage")).isEqualTo("SYNTHETIC-1009-BAGGAGE-SECRET");
|
||||
listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot));
|
||||
}
|
||||
assertThat(Span.current().getSpanContext().isValid()).isFalse();
|
||||
});
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).hasSize(1);
|
||||
assertThat(exporter.records.getFirst().getSpanContext()).isEqualTo(spanContext);
|
||||
assertThat(exporter.records.getFirst().getSpanContext().isSampled()).isFalse();
|
||||
var exported = exporter.records.getFirst();
|
||||
for (String value : List.of(exported.getAttributes().toString(), String.valueOf(exported.getBody().asString()),
|
||||
exported.getResource().getAttributes().toString())) {
|
||||
assertThat(value).doesNotContain("sensitive.mdc", "sensitive.baggage",
|
||||
"SYNTHETIC-1009-MDC-SECRET", "SYNTHETIC-1009-BAGGAGE-SECRET");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void saturationDoesNotWaitForReceiverAndShutdownIsBounded() throws Exception {
|
||||
var properties = new BusinessLogsProperties();
|
||||
properties.setMaxQueueSize(2);
|
||||
properties.setMaxExportBatchSize(1);
|
||||
properties.setScheduleDelay(Duration.ofMillis(10));
|
||||
properties.setShutdownTimeout(Duration.ofMillis(100));
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var entered = new CountDownLatch(1);
|
||||
var pending = new CompletableResultCode();
|
||||
var exporter = new CapturingExporter() {
|
||||
@Override public CompletableResultCode export(Collection<LogRecordData> records) { entered.countDown(); return pending; }
|
||||
@Override public CompletableResultCode shutdown() { pending.fail(); return CompletableResultCode.ofSuccess(); }
|
||||
};
|
||||
var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics);
|
||||
var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
try {
|
||||
listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot));
|
||||
assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < 1000; i++) { listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); }
|
||||
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
assertThat(elapsed).isLessThan(1000);
|
||||
assertThat(metrics.enqueued.count()).isEqualTo(2);
|
||||
assertThat(metrics.queueFull.count()).isEqualTo(999);
|
||||
start = System.nanoTime();
|
||||
pipeline.close();
|
||||
long shutdown = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
assertThat(shutdown).isLessThan(1000);
|
||||
System.out.printf("BUSINESS_LOGS saturation: 1000 caller events in %d ms, drops=999, shutdown=%d ms%n", elapsed, shutdown);
|
||||
} finally { pipeline.close(); pending.fail(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperAndExporterExceptionsCannotEscapeBusinessThread() {
|
||||
var properties = new BusinessLogsProperties();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
var exporter = new CapturingExporter() {
|
||||
@Override public CompletableResultCode export(Collection<LogRecordData> records) { throw new IllegalStateException("must not be logged"); }
|
||||
};
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) {
|
||||
var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics);
|
||||
assertThatCode(() -> listener.onEvent(null)).doesNotThrowAnyException();
|
||||
assertThat(metrics.errors.count()).isEqualTo(1);
|
||||
assertThatCode(() -> listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot))).doesNotThrowAnyException();
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(metrics.exportFailures.count()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualNontransactionalDownloadProducerEmitsOnceAfterPermissionsAndNeverForReviewOrDeniedAccess() {
|
||||
var properties = new BusinessLogsProperties();
|
||||
var exporter = new CapturingExporter();
|
||||
var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry());
|
||||
try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics);
|
||||
var context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(BusinessEventListener.class,
|
||||
() -> new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics));
|
||||
context.refresh();
|
||||
var namespaces = mock(NamespaceRepository.class);
|
||||
var skills = mock(SkillRepository.class);
|
||||
var versions = mock(SkillVersionRepository.class);
|
||||
var storage = mock(ObjectStorageService.class);
|
||||
var skill = new Skill(7L, "never-export-this-slug", "owner", SkillVisibility.PUBLIC);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(skill, "id", 1L);
|
||||
skill.setLatestVersionId(2L);
|
||||
var version = new SkillVersion(1L, "1.0.0", "owner");
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(version, "id", 2L);
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
version.setDownloadReady(true);
|
||||
var namespace = new Namespace("test", "Never export namespace name", "owner");
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", 7L);
|
||||
when(namespaces.findBySlug("test")).thenReturn(java.util.Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(7L, skill.getSlug())).thenReturn(List.of(skill));
|
||||
when(versions.findById(2L)).thenReturn(java.util.Optional.of(version));
|
||||
when(storage.exists("packages/1/2/bundle.zip")).thenReturn(true);
|
||||
when(storage.getMetadata("packages/1/2/bundle.zip")).thenReturn(new ObjectMetadata(12, "application/zip", Instant.now()));
|
||||
when(storage.generatePresignedUrl(anyString(), any(), anyString())).thenReturn("https://secret-signed-url.invalid/?token=not-exported");
|
||||
var service = new SkillDownloadService(namespaces, skills, versions, mock(SkillVersionStatsRepository.class),
|
||||
mock(SkillFileRepository.class), mock(SkillTagRepository.class), storage, new VisibilityChecker(),
|
||||
context, new SkillSlugResolutionService(skills));
|
||||
var response = service.downloadLatest("test", skill.getSlug(), null, java.util.Map.of());
|
||||
assertThat(response.presignedUrl()).contains("secret-signed-url");
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).hasSize(1);
|
||||
assertThat(exporter.records.getFirst().getAttributes().get(AttributeKey.stringKey("user.id"))).isNull();
|
||||
assertThat(exporter.records.getFirst().getAttributes().toString()).doesNotContain("not-exported", skill.getSlug());
|
||||
service.downloadReviewVersion(skill, version);
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
assertThatThrownBy(() -> service.downloadLatest("test", skill.getSlug(), null, java.util.Map.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
assertThatThrownBy(() -> service.downloadLatest("test", skill.getSlug(), "stranger", java.util.Map.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
pipeline.forceFlush().join(5, TimeUnit.SECONDS);
|
||||
assertThat(exporter.records).hasSize(1);
|
||||
System.out.println("BUSINESS_LOGS real download service: anonymous prepared=1, review/anonymous-private/unauthorized-private=0");
|
||||
}
|
||||
}
|
||||
|
||||
static class CapturingExporter implements LogRecordExporter {
|
||||
final List<LogRecordData> records = new CopyOnWriteArrayList<>();
|
||||
@Override public CompletableResultCode export(Collection<LogRecordData> records) { this.records.addAll(records); return CompletableResultCode.ofSuccess(); }
|
||||
@Override public CompletableResultCode flush() { return CompletableResultCode.ofSuccess(); }
|
||||
@Override public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"),
|
||||
RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"),
|
||||
RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"),
|
||||
RouteAuthorizationPolicy.roles(null, "/actuator/metrics", "SUPER_ADMIN"),
|
||||
RouteAuthorizationPolicy.roles(null, "/actuator/metrics/**", "SUPER_ADMIN"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/star"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/star"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Immutable producer context, never an entity or a request object. No telemetry dependency.
|
||||
* The timestamp is event occurrence (immediately before publication), not transaction commit time.
|
||||
*/
|
||||
public record BusinessEventSnapshot(Long namespaceId, SkillVisibility visibility, String actorId,
|
||||
String actorType, String source, Instant occurredAt) {
|
||||
public static BusinessEventSnapshot capture(Skill skill, String actorId, String source) {
|
||||
return capture(skill, actorId, "user", source);
|
||||
}
|
||||
|
||||
public static BusinessEventSnapshot capture(Skill skill, String actorId, String actorType, String source) {
|
||||
// BuiltinSkillInitializer reserves this identity and verifies isSystemAccount before publishing.
|
||||
// It uses the ordinary privileged publish path; never mislabel that service account as human.
|
||||
if ("builtin-skill-publisher".equals(actorId)) { actorType = "system"; }
|
||||
return new BusinessEventSnapshot(skill.getNamespaceId(), skill.getVisibility(), actorId,
|
||||
actorType, source, Instant.now());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {}
|
||||
public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId,
|
||||
String submitterId, BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {
|
||||
this(promotionId, skillId, reviewerId, submitterId, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {}
|
||||
public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId,
|
||||
String submitterId, String reason, BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {
|
||||
this(promotionId, skillId, reviewerId, submitterId, reason, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {}
|
||||
public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId,
|
||||
String submitterId, BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {
|
||||
this(reviewId, skillId, versionId, reviewerId, submitterId, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId, String reason) {}
|
||||
public record ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId,
|
||||
String submitterId, String reason, BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId, String reason) {
|
||||
this(reviewId, skillId, versionId, reviewerId, submitterId, reason, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
/** Marker for existing skill facts with a producer-captured scalar analytics snapshot. */
|
||||
public interface SkillBusinessEvent {
|
||||
Long skillId();
|
||||
BusinessEventSnapshot snapshot();
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
public record SkillDownloadedEvent(Long skillId, Long versionId) {}
|
||||
public record SkillDownloadedEvent(Long skillId, Long versionId,
|
||||
BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public SkillDownloadedEvent(Long skillId, Long versionId) {
|
||||
this(skillId, versionId, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {}
|
||||
public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId,
|
||||
BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {
|
||||
this(skillId, versionId, publisherId, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,9 @@ package com.iflytek.skillhub.domain.event;
|
|||
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
|
||||
public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {}
|
||||
public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus,
|
||||
BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {
|
||||
this(skillId, oldStatus, newStatus, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) {}
|
||||
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished,
|
||||
BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) {
|
||||
this(skillId, versionId, actorUserId, wasPublished, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,8 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
|
||||
/** Access changed without publishing a new package version. */
|
||||
public record SkillVisibilityChangedEvent(Long skillId, SkillVisibility previousVisibility,
|
||||
SkillVisibility visibility) {}
|
||||
SkillVisibility visibility, BusinessEventSnapshot snapshot) implements SkillBusinessEvent {
|
||||
public SkillVisibilityChangedEvent(Long skillId, SkillVisibility previousVisibility, SkillVisibility visibility) {
|
||||
this(skillId, previousVisibility, visibility, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.review;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditDetail;
|
||||
|
|
@ -294,10 +296,11 @@ public class PromotionService {
|
|||
PromotionRequest savedRequest = promotionRequestRepository.save(approvedRequest);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
targetSkill.getId(), newVersion.getId(), reviewerId));
|
||||
targetSkill.getId(), newVersion.getId(), reviewerId,
|
||||
BusinessEventSnapshot.capture(targetSkill, reviewerId, "promotion")));
|
||||
eventPublisher.publishEvent(new PromotionApprovedEvent(
|
||||
approvedRequest.getId(), approvedRequest.getSourceSkillId(),
|
||||
reviewerId, approvedRequest.getSubmittedBy()));
|
||||
reviewerId, approvedRequest.getSubmittedBy(), BusinessEventSnapshot.capture(sourceSkill, reviewerId, "promotion")));
|
||||
governanceNotificationService.notifyUser(
|
||||
approvedRequest.getSubmittedBy(),
|
||||
"PROMOTION",
|
||||
|
|
@ -396,9 +399,12 @@ public class PromotionService {
|
|||
}
|
||||
syncPromotionRequestState(request, ReviewTaskStatus.REJECTED, reviewerId, comment);
|
||||
entityManager.detach(request);
|
||||
Skill sourceSkill = request.getSourceSkillId() == null ? null
|
||||
: skillRepository.findById(request.getSourceSkillId()).orElse(null);
|
||||
eventPublisher.publishEvent(new PromotionRejectedEvent(
|
||||
request.getId(), request.getSourceSkillId(),
|
||||
reviewerId, request.getSubmittedBy(), comment));
|
||||
reviewerId, request.getSubmittedBy(), comment,
|
||||
sourceSkill == null ? null : BusinessEventSnapshot.capture(sourceSkill, reviewerId, "promotion")));
|
||||
governanceNotificationService.notifyUser(
|
||||
request.getSubmittedBy(),
|
||||
"PROMOTION",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.review;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -289,10 +291,11 @@ public class ReviewService {
|
|||
|
||||
if (avatarParticipant != null) { avatarParticipant.decide(task, true, reviewerId, comment); }
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
skill.getId(), skillVersion.getId(), reviewerId));
|
||||
skill.getId(), skillVersion.getId(), reviewerId,
|
||||
BusinessEventSnapshot.capture(skill, reviewerId, "review")));
|
||||
eventPublisher.publishEvent(new ReviewApprovedEvent(
|
||||
task.getId(), skill.getId(), skillVersion.getId(),
|
||||
reviewerId, task.getSubmittedBy()));
|
||||
reviewerId, task.getSubmittedBy(), BusinessEventSnapshot.capture(skill, reviewerId, "review")));
|
||||
governanceNotificationService.notifyUser(
|
||||
task.getSubmittedBy(),
|
||||
"REVIEW",
|
||||
|
|
@ -358,7 +361,7 @@ public class ReviewService {
|
|||
if (avatarParticipant != null) { avatarParticipant.decide(task, false, reviewerId, comment); }
|
||||
eventPublisher.publishEvent(new ReviewRejectedEvent(
|
||||
task.getId(), skill.getId(), skillVersion.getId(),
|
||||
reviewerId, task.getSubmittedBy(), comment));
|
||||
reviewerId, task.getSubmittedBy(), comment, BusinessEventSnapshot.capture(skill, reviewerId, "review")));
|
||||
governanceNotificationService.notifyUser(
|
||||
task.getSubmittedBy(),
|
||||
"REVIEW",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.SkillDownloadedEvent;
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
|
|
@ -172,15 +173,16 @@ public class SkillDownloadService {
|
|||
|
||||
// Only increment download count for PUBLISHED versions
|
||||
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
recordPublishedDownload(skill, version);
|
||||
recordPublishedDownload(skill, version, currentUserId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void recordPublishedDownload(Skill skill, SkillVersion version) {
|
||||
private void recordPublishedDownload(Skill skill, SkillVersion version, String currentUserId) {
|
||||
skillRepository.incrementDownloadCount(skill.getId());
|
||||
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
|
||||
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
|
||||
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId(),
|
||||
BusinessEventSnapshot.capture(skill, currentUserId, "download")));
|
||||
}
|
||||
|
||||
private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.iflytek.skillhub.domain.audit.AuditDetail;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.security.SecurityScanService;
|
||||
|
|
@ -139,7 +140,8 @@ public class SkillGovernanceService {
|
|||
skill.setUpdatedBy(actorUserId);
|
||||
Skill saved = skillRepository.save(skill);
|
||||
auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason));
|
||||
eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED));
|
||||
eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED,
|
||||
BusinessEventSnapshot.capture(skill, actorUserId, "governance")));
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +182,8 @@ public class SkillGovernanceService {
|
|||
skill.setUpdatedBy(actorUserId);
|
||||
Skill saved = skillRepository.save(skill);
|
||||
auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null);
|
||||
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE));
|
||||
eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE,
|
||||
BusinessEventSnapshot.capture(skill, actorUserId, "governance")));
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +356,8 @@ public class SkillGovernanceService {
|
|||
version.setYankReason(reason);
|
||||
version.setDownloadReady(false);
|
||||
SkillVersion saved = skillVersionRepository.save(version);
|
||||
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
|
||||
Optional<Skill> eventSkill = skillRepository.findById(version.getSkillId());
|
||||
eventSkill.ifPresent(skill -> {
|
||||
if (versionId.equals(skill.getLatestVersionId())) {
|
||||
Optional<SkillVersion> fallback = findLatestPublishedVersion(skill.getId());
|
||||
skill.setLatestVersionId(fallback.map(SkillVersion::getId).orElse(null));
|
||||
|
|
@ -364,7 +368,8 @@ public class SkillGovernanceService {
|
|||
});
|
||||
auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason));
|
||||
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
|
||||
version.getSkillId(), versionId, actorUserId, true));
|
||||
version.getSkillId(), versionId, actorUserId, true,
|
||||
eventSkill.map(skill -> BusinessEventSnapshot.capture(skill, actorUserId, "governance")).orElse(null)));
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.review.ReviewAvatarParticipant;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -972,7 +974,9 @@ public class SkillPublishService {
|
|||
skillRepository.save(skill);
|
||||
|
||||
if (autoPublish) {
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId));
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId,
|
||||
BusinessEventSnapshot.capture(skill, publisherId,
|
||||
forceAutoPublish ? "system" : "user", "auto_publish")));
|
||||
}
|
||||
|
||||
// 13. Return identifiers for the created version
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
|
|
@ -191,7 +193,8 @@ public class SkillReviewSubmitService {
|
|||
skillRepository.save(skill);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
skill.getId(), version.getId(), actorUserId));
|
||||
skill.getId(), version.getId(), actorUserId,
|
||||
BusinessEventSnapshot.capture(skill, actorUserId, "private_confirm")));
|
||||
}
|
||||
|
||||
private void assertCanManageLifecycle(Skill skill, String actorUserId,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service;
|
|||
|
||||
import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent;
|
||||
import com.iflytek.skillhub.domain.event.BusinessEventSnapshot;
|
||||
import com.iflytek.skillhub.domain.security.*;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.domain.review.*;
|
||||
|
|
@ -142,6 +143,7 @@ public class SkillVisibilityService {
|
|||
skill.setVisibility(target);
|
||||
skill.setUpdatedBy(actor);
|
||||
skills.save(skill);
|
||||
events.publishEvent(new SkillVisibilityChangedEvent(skill.getId(), previous, target));
|
||||
events.publishEvent(new SkillVisibilityChangedEvent(skill.getId(), previous, target,
|
||||
BusinessEventSnapshot.capture(skill, actor, "visibility")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -593,6 +593,14 @@ class PromotionServiceTest {
|
|||
assertEquals(NEW_SKILL_ID, event.skillId());
|
||||
assertEquals(NEW_VERSION_ID, event.versionId());
|
||||
assertEquals(REVIEWER_ID, event.publisherId());
|
||||
assertEquals(TARGET_NAMESPACE_ID, event.snapshot().namespaceId());
|
||||
assertEquals(REVIEWER_ID, event.snapshot().actorId());
|
||||
assertEquals("promotion", event.snapshot().source());
|
||||
ArgumentCaptor<com.iflytek.skillhub.domain.event.PromotionApprovedEvent> approved =
|
||||
ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.PromotionApprovedEvent.class);
|
||||
verify(eventPublisher).publishEvent(approved.capture());
|
||||
assertEquals(5L, approved.getValue().snapshot().namespaceId());
|
||||
assertEquals(REVIEWER_ID, approved.getValue().snapshot().actorId());
|
||||
|
||||
// Verify targetSkillId updated on promotion request
|
||||
verify(promotionRequestRepository).save(approvedRequest);
|
||||
|
|
@ -926,6 +934,7 @@ class PromotionServiceTest {
|
|||
void shouldRejectPromotionSuccessfully() {
|
||||
PromotionRequest pr = createPendingPromotion();
|
||||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill()));
|
||||
when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()))
|
||||
|
|
@ -940,7 +949,11 @@ class PromotionServiceTest {
|
|||
assertEquals(Instant.now(CLOCK), result.getReviewedAt());
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion());
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
var rejected = ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.PromotionRejectedEvent.class);
|
||||
verify(eventPublisher).publishEvent(rejected.capture());
|
||||
assertEquals(SOURCE_SKILL_ID, rejected.getValue().skillId());
|
||||
assertEquals(5L, rejected.getValue().snapshot().namespaceId());
|
||||
assertEquals(REVIEWER_ID, rejected.getValue().snapshot().actorId());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -395,6 +395,14 @@ class ReviewServiceTest {
|
|||
assertEquals(SKILL_ID, event.skillId());
|
||||
assertEquals(SKILL_VERSION_ID, event.versionId());
|
||||
assertEquals(REVIEWER_ID, event.publisherId());
|
||||
assertEquals(NAMESPACE_ID, event.snapshot().namespaceId());
|
||||
assertEquals(REVIEWER_ID, event.snapshot().actorId());
|
||||
assertEquals("review", event.snapshot().source());
|
||||
ArgumentCaptor<com.iflytek.skillhub.domain.event.ReviewApprovedEvent> approved =
|
||||
ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.ReviewApprovedEvent.class);
|
||||
verify(eventPublisher).publishEvent(approved.capture());
|
||||
assertEquals(REVIEWER_ID, approved.getValue().snapshot().actorId());
|
||||
assertNotNull(approved.getValue().snapshot().occurredAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ class SkillGovernanceServiceTest {
|
|||
|
||||
assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED);
|
||||
verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}");
|
||||
verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class));
|
||||
var archived = org.mockito.ArgumentCaptor.forClass(SkillStatusChangedEvent.class);
|
||||
verify(eventPublisher).publishEvent(archived.capture());
|
||||
assertThat(archived.getValue().snapshot().actorId()).isEqualTo("owner");
|
||||
assertThat(archived.getValue().snapshot().namespaceId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -164,7 +167,10 @@ class SkillGovernanceServiceTest {
|
|||
|
||||
assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE);
|
||||
verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null);
|
||||
verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class));
|
||||
var restored = org.mockito.ArgumentCaptor.forClass(SkillStatusChangedEvent.class);
|
||||
verify(eventPublisher).publishEvent(restored.capture());
|
||||
assertThat(restored.getValue().snapshot().actorId()).isEqualTo("owner");
|
||||
assertThat(restored.getValue().newStatus()).isEqualTo(SkillStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -211,7 +217,10 @@ class SkillGovernanceServiceTest {
|
|||
assertThat(result.getYankedBy()).isEqualTo("owner");
|
||||
assertThat(result.isDownloadReady()).isFalse();
|
||||
verify(auditLogService).record("owner", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}");
|
||||
verify(eventPublisher).publishEvent(any(com.iflytek.skillhub.domain.event.SkillVersionYankedEvent.class));
|
||||
var yanked = org.mockito.ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.SkillVersionYankedEvent.class);
|
||||
verify(eventPublisher).publishEvent(yanked.capture());
|
||||
assertThat(yanked.getValue().snapshot().actorId()).isEqualTo("owner");
|
||||
assertThat(yanked.getValue().snapshot().namespaceId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -878,7 +878,13 @@ class SkillPublishServiceTest {
|
|||
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
|
||||
verify(skillRepository).save(argThat(savedSkill ->
|
||||
savedSkill.getLatestVersionId() != null && savedSkill.getLatestVersionId().equals(10L)));
|
||||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
var published = ArgumentCaptor.forClass(SkillPublishedEvent.class);
|
||||
verify(eventPublisher).publishEvent(published.capture());
|
||||
assertEquals(1L, published.getValue().snapshot().namespaceId());
|
||||
assertEquals(publisherId, published.getValue().snapshot().actorId());
|
||||
assertEquals("user", published.getValue().snapshot().actorType());
|
||||
assertEquals("auto_publish", published.getValue().snapshot().source());
|
||||
assertNotNull(published.getValue().snapshot().occurredAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@ class SkillReviewSubmitServiceTest {
|
|||
assertEquals(skillId, event.skillId());
|
||||
assertEquals(versionId, event.versionId());
|
||||
assertEquals(userId, event.publisherId());
|
||||
assertEquals(namespaceId, event.snapshot().namespaceId());
|
||||
assertEquals(SkillVisibility.PRIVATE, event.snapshot().visibility());
|
||||
assertEquals(userId, event.snapshot().actorId());
|
||||
assertEquals("user", event.snapshot().actorType());
|
||||
assertEquals("private_confirm", event.snapshot().source());
|
||||
assertNotNull(event.snapshot().occurredAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -51,7 +51,12 @@ class SkillVisibilityServiceTest {
|
|||
assertThat(skill.getLatestVersionId()).isEqualTo(3L);
|
||||
verify(versions, never()).save(any());
|
||||
verify(reviews, never()).save(any());
|
||||
verify(events).publishEvent(any(SkillVisibilityChangedEvent.class));
|
||||
var event = org.mockito.ArgumentCaptor.forClass(SkillVisibilityChangedEvent.class);
|
||||
verify(events).publishEvent(event.capture());
|
||||
assertThat(event.getValue().snapshot().namespaceId()).isEqualTo(2L);
|
||||
assertThat(event.getValue().snapshot().visibility()).isEqualTo(SkillVisibility.PRIVATE);
|
||||
assertThat(event.getValue().snapshot().actorId()).isEqualTo("owner");
|
||||
assertThat(event.getValue().previousVisibility()).isEqualTo(SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -62,9 +67,13 @@ class SkillVisibilityServiceTest {
|
|||
assertThat(task.getRequestedVisibility()).isEqualTo(SkillVisibility.PUBLIC);
|
||||
assertThat(task.getVisibilityVersionId()).isEqualTo(3L);
|
||||
assertThat(task.getSkillVersionId()).isNull();
|
||||
verifyNoInteractions(events);
|
||||
service.approve(task, "admin");
|
||||
assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PUBLIC);
|
||||
verify(versions, never()).save(any());
|
||||
var event = org.mockito.ArgumentCaptor.forClass(SkillVisibilityChangedEvent.class);
|
||||
verify(events).publishEvent(event.capture());
|
||||
assertThat(event.getValue().snapshot().actorId()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue