Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ src/main/java/com/fowoco/server/
| Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 |
| 근로자 명단 가져오기 | [Worker Import 가이드](docs/worker-import.md) | CSV/XLSX 업로드부터 검증·수정·등록까지의 API 순서 |
| Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 |
| AI 단계별 성능 측정 | [AI 파이프라인 관측·Prometheus 가이드](docs/ai-pipeline-observability.md) | PLAN·Slot·ANALYZE·Renewal 구간의 정량 평가와 로컬 Prometheus 확인 기준 |
| 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 |
| 구현 계획·업무 상태 | [Server Roadmap](https://github.com/orgs/fowoco/projects/3) · [Issues](https://github.com/fowoco/server/issues) | 실제 담당자, 우선순위와 진행 상태 |
| 전체 설명·운영 가이드 | [Server Wiki](https://github.com/fowoco/server/wiki) | 초보자용 아키텍처·API·배포 설명 |
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
implementation 'org.apache.poi:poi-ooxml:5.4.0'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
Expand Down
20 changes: 20 additions & 0 deletions compose.observability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: fowoco-server-observability

services:
prometheus:
image: prom/prometheus:v3.13.2
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
ports:
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- fowoco-prometheus-data:/prometheus
extra_hosts:
- "host.docker.internal:host-gateway"

volumes:
fowoco-prometheus-data:
176 changes: 176 additions & 0 deletions docs/ai-pipeline-observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# AI 파이프라인 소요시간 측정 가이드

## 목적

이 문서는 FOWOCO의 `PLAN → Slot 조회 → ANALYZE → Renewal → 문서 생성` 흐름을
Server 경계에서 반복 측정하고, 데모 발표의 정량 평가 근거로 정리하는 방법을
설명합니다.

Server는 AI Runtime HTTP 왕복시간을 측정합니다. BERT·A.X·LangGraph 내부 Node의
실행시간과 모델 정확도는 AI 팀의 평가 결과를 사용합니다. 두 값을 같은 지표로
혼합하지 않습니다.

## 기록되는 단계

### AiRun

| Phase | Stage | 의미 |
| --- | --- | --- |
| `PLAN` | `PLAN_RUNTIME_CALL` | AI Runtime의 PLAN 요청·응답과 Server 계약 검증 |
| `ANALYZE` | `SLOT_RESOLUTION` | tenant와 allow-list를 적용한 DB 업무정보 조회 |
| `ANALYZE` | `ANALYZE_RUNTIME_CALL` | PLAN 결정을 재사용한 ANALYZE 요청·응답과 검증 |
| `PLAN/ANALYZE` | `RESULT_PERSIST` | 검증된 결과 저장과 공개 상태 Event 발행 |
| `PIPELINE/ANALYZE` | `TOTAL` | 자동 실행 묶음의 전체 시간. HR 답변 대기시간은 포함하지 않음 |

### Renewal

| Stage | 의미 |
| --- | --- |
| `CONTEXT_LOAD` | Task·Worker·Company·OCR·Slot Context 조회 |
| `RENEWAL_RUNTIME_CALL` | Renewal Agent HTTP 왕복과 계약 검증 |
| `DOCUMENT_GENERATION` | HWP/HWPX 생성 결과 준비 |
| `RESULT_APPLY` | 안내 초안·Task 상태·생성 파일 반영 |
| `TOTAL` | Renewal 자동 실행 전체 시간 |

구조화 로그에는 추적용 `request_id`, `attempt_id`, 단계, 성공·실패, 소요시간과
안전한 오류 코드만 남습니다. 발화문, 실명, Slot 실제 값, Token과 AI 응답 본문은
남기지 않습니다.

## 로컬 실행

### 1. Server 실행

Prometheus endpoint는 기본적으로 인증 없이 접근할 수 없습니다. 로컬 측정할 때만
`observability` profile을 함께 활성화합니다.

```bash
SERVER_ADDRESS=0.0.0.0 SPRING_PROFILES_ACTIVE=local,observability ./gradlew bootRun
```

Docker Desktop의 Prometheus가 호스트의 Server에 접근하도록 로컬 실행 주소만
`0.0.0.0`으로 변경합니다. 신뢰할 수 있는 개발 네트워크에서만 사용하고 측정 후
Server를 종료합니다. 이 값을 주지 않으면 Server는 계속 `127.0.0.1`에만 바인딩됩니다.

다음 주소에서 Prometheus 형식의 원시 지표를 확인합니다.

```text
http://127.0.0.1:8080/actuator/prometheus
```

`observability` profile은 `prod`와 함께 활성화해도 공개 Security Chain이 생성되지
않도록 막혀 있습니다. 운영 환경에서 Prometheus를 연결할 때는 별도 내부망 인증
정책을 먼저 정합니다.

### 2. Prometheus 실행

Server가 실행된 상태에서 다음 명령을 사용합니다.

```bash
docker compose -f compose.observability.yml up -d
```

- Prometheus: http://127.0.0.1:9090
- 수집 상태: http://127.0.0.1:9090/targets

측정을 마치고 컨테이너만 중지할 때는 다음을 사용합니다.

```bash
docker compose -f compose.observability.yml down
```

로컬 측정 데이터까지 삭제하려면 명시적으로 `down -v`를 사용합니다.

## 주요 Metric

| Metric | 설명 |
| --- | --- |
| `fowoco_ai_pipeline_stage_seconds` | AiRun 단계별 Server 관측시간 |
| `fowoco_ai_analysis_outcomes_total` | 검증된 분석 응답 Outcome 횟수 |
| `fowoco_ai_pipeline_failures_total` | 단계와 안전한 오류 코드별 실패 횟수 |
| `fowoco_renewal_stage_seconds` | Renewal 단계별 Server 관측시간 |
| `fowoco_renewal_failures_total` | Renewal 오류 코드별 실패 횟수 |

Timer는 Prometheus에서 `_count`, `_sum`, `_max`, `_bucket` 시계열로 노출됩니다.
Prometheus 시간 단위는 초이고 구조화 로그의 `duration_ms`는 밀리초입니다.

## PromQL 예시

최근 30분 PLAN 평균시간:

```promql
sum(increase(fowoco_ai_pipeline_stage_seconds_sum{phase="PLAN",stage="PLAN_RUNTIME_CALL",status="SUCCESS"}[30m]))
/
sum(increase(fowoco_ai_pipeline_stage_seconds_count{phase="PLAN",stage="PLAN_RUNTIME_CALL",status="SUCCESS"}[30m]))
```

최근 30분 단계별 중앙값:

```promql
histogram_quantile(
0.50,
sum by (le, phase, stage) (
increase(fowoco_ai_pipeline_stage_seconds_bucket{status="SUCCESS"}[30m])
)
)
```

최근 30분 단계별 95백분위:

```promql
histogram_quantile(
0.95,
sum by (le, phase, stage) (
increase(fowoco_ai_pipeline_stage_seconds_bucket{status="SUCCESS"}[30m])
)
)
```

분석 결과별 횟수:

```promql
sum by (outcome) (fowoco_ai_analysis_outcomes_total)
```

오류 코드별 실패 단계 횟수:

```promql
sum by (phase, stage, failure_code) (fowoco_ai_pipeline_failures_total)
```

한 번의 오류는 실제 실패 단계와 이를 감싼 `TOTAL` 단계에 각각 기록될 수 있습니다.
실패한 요청 수만 확인할 때는 `stage="TOTAL"`을, 원인 단계를 확인할 때는
`stage!="TOTAL"`을 사용합니다.

## 정량 평가 절차

1. Server와 AI Runtime을 기동합니다.
2. 모델 최초 로딩을 위한 워밍업 요청을 2회 실행하고 결과에서 제외합니다.
3. 동일한 합성 발화와 Demo Worker를 사용해 시나리오별 최소 10회 실행합니다.
4. 각 실행의 `request_id`, cold/warm 여부, Outcome과 모델 버전을 별도 표에 기록합니다.
5. Prometheus에서 단계별 중앙값·최댓값·실패 횟수를 조회합니다.
6. AI 팀의 모델 내부 추론시간·정확도와 Server E2E 시간을 구분해 보고합니다.

권장 시나리오:

| 시나리오 | 확인할 결과 |
| --- | --- |
| 정상 체류연장 | `PLAN → Slot → ANALYZE → REVIEW_REQUIRED` |
| 누락 정보 | `NEEDS_INFO → HR 답변 → ANALYZE` |
| 범위 밖 발화 | `OUT_OF_SCOPE`, Slot·ANALYZE 미실행 |
| Runtime 지연 | 240초 제한과 `DEADLINE_EXCEEDED` |
| Renewal 문서 생성 | Context·Runtime·문서 생성·결과 반영 |

평가표 예시:

| 시나리오 | 반복 | PLAN | Slot | ANALYZE | 전체 | Outcome | 비고 |
| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |
| 정상 체류연장 | 1 | 0.84s | 0.02s | 0.41s | 1.34s | REVIEW_REQUIRED | warm |

Server 지표만으로 `HR 업무시간이 몇 % 절감됐다`고 결론 내리지 않습니다. 이 효과를
제시하려면 동일 업무의 수작업 시간과 FOWOCO 사용시간을 별도로 측정해야 합니다.

## 보안과 Metric tag

허용 tag는 `phase`, `stage`, `status`, `outcome`, `failure_code`처럼 값의 종류가
제한된 항목뿐입니다. `requestId`, `attemptId`, `companyId`, `workerId`, `taskId`,
실명과 연락처를 Metric tag에 넣지 않습니다. 추적 ID는 구조화 로그에서만 사용합니다.
19 changes: 19 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ SMTP 비밀번호와 재설정 원본 token은 Git, Issue, 일반 로그에 기
DB pool은 기본 최대 10개입니다. 클러스터 규모에 따라 `DB_MAX_POOL_SIZE`, `DB_MIN_IDLE`,
`DB_CONNECTION_TIMEOUT_MS`, `DB_VALIDATION_TIMEOUT_MS`로 제한합니다.

## 관측 설정 경계

Server는 AiRun·Renewal 구간의 Micrometer 지표를 생성하지만, 현재 데모 배포에서는
Prometheus를 클러스터에 함께 배포하지 않습니다.

- `/actuator/prometheus`는 기본 보안 Chain에서 보호됩니다.
- 로컬 `observability` profile은 `prod`와 함께 활성화해도 공개 Chain이 생성되지
않습니다.
- 배포 환경에서 수집이 필요해지면 Infra가 내부 Service·NetworkPolicy·인증 또는
별도 management port를 먼저 구성합니다.
- 공개 Ingress와 `CORS_ALLOWED_ORIGINS`에 Prometheus endpoint를 추가하지 않습니다.
- Metric에는 `companyId`, `workerId`, `taskId`, 요청·시도 ID와 개인정보를 tag로
넣지 않습니다.

따라서 현재 `server-env`에 `SPRING_PROFILES_ACTIVE=prod,observability`를 설정하면
안 됩니다. 로컬 측정과 정량 평가 절차는
[AI 파이프라인 관측 가이드](ai-pipeline-observability.md)를 사용합니다.

현재 Infra에 HTTPS/TLS와 `RELEASED` Workflow Catalog 배포가 없으면 `prod` 완료 조건을
충족하지 못합니다. 임시 HTTP 주소와 DRAFT Catalog는 개발 Smoke에만 사용합니다.

Expand Down Expand Up @@ -143,6 +161,7 @@ Seed의 수량과 고정 ID도 첫 기동과 같아야 합니다.
7. Worker Link 대표 흐름 확인
8. SMS가 활성화된 환경에서는 실제 수신·링크 접속·중복 발송 방지 확인
9. SMTP가 활성화된 환경에서는 재설정 메일 수신·링크 token·새 비밀번호 로그인 확인
10. 로그에서 AiRun·Renewal `TOTAL` 단계와 안전한 `failure_code`가 기록되는지 확인

Runtime 장애 테스트에서는 가짜 AI 결과를 만들지 않고 안전한 오류 또는 수동 처리 상태로
남아야 합니다.
Expand Down
24 changes: 24 additions & 0 deletions docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ Server는 AI에 보낼 수 있는 필드를 typed DTO로 제한하고 요청 전

상세 계약은 [AI Runtime 계약 문서](ai-runtime-contract.md)를 확인합니다.

## AI 파이프라인 로컬 관측

Server는 AI 내부 Node를 추측하지 않고 자신이 실제로 관측할 수 있는 구간만
구조화 로그와 Micrometer 지표로 기록합니다.

- AiRun: PLAN 호출, Slot 조회, ANALYZE 호출, 결과 저장, 전체 자동 실행
- Renewal: Context 조회, Runtime 호출, 문서 생성, 결과 반영, 전체 실행
- `request_id`와 `attempt_id`는 로그 추적에만 사용합니다.
- Metric tag에는 종류가 제한된 `phase`, `stage`, `status`, `outcome`,
`failure_code`만 사용합니다.
- 발화문, Slot 값, 근로자·Task ID와 개인정보는 로그 본문과 Metric tag에 넣지
않습니다.

로컬 Prometheus를 함께 실행할 때는 다음과 같이 별도 profile을 명시합니다.

```bash
SERVER_ADDRESS=0.0.0.0 SPRING_PROFILES_ACTIVE=local,observability ./gradlew bootRun
docker compose -f compose.observability.yml up -d
```

운영 `prod`에서는 위 공개 profile을 사용하지 않습니다. 단계 정의, PromQL과 반복
측정 방법은 [AI 파이프라인 관측 가이드](ai-pipeline-observability.md)를 확인합니다.

## 이벤트 유실 방지와 재처리

Task 생성·취소처럼 후속 처리가 필요한 변경은 업무 데이터와
Expand Down Expand Up @@ -270,5 +293,6 @@ Client가 안전한 형식의 `X-Request-Id`를 보내면 Server가 응답과
- [프로젝트 구조](project-structure.md)
- [Transactional Outbox 운영 가이드](reliability/transactional-outbox.md)
- [PostgreSQL Runtime Timeout 운영 가이드](reliability/postgresql-runtime-timeouts.md)
- [AI 파이프라인 관측·Prometheus 가이드](ai-pipeline-observability.md)
- [ADR 목록](adr/README.md)
- [PostgreSQL RLS 적용 가이드](database/postgresql-rls-rollout.md)
8 changes: 8 additions & 0 deletions docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ server/
| `aiintegration` | AI Runtime HTTP 계약과 Client |
| `reliability` | Outbox, event 전달과 복구 |

관측 코드도 기능 소유권을 따릅니다. AiRun 단계 지표는 `airun`, Renewal 단계
지표는 `task`가 기록합니다. `common`은 Actuator endpoint의 보안·공통 설정만
담당하고, Prometheus의 배포·보존·알림은 `infra` 저장소가 소유합니다. 별도
`observability` 도메인 패키지나 Metric 저장용 DB 테이블은 만들지 않습니다.

## 기능 내부 구조

기능 코드가 커지면 아래 방향으로 확장합니다.
Expand Down Expand Up @@ -115,6 +120,9 @@ PostgreSQL AI Runtime
- `client`는 화면 상태와 사용자 상호작용을 소유합니다.
- `infra`는 통합 배포, 네트워크, Secret과 관측 인프라를 소유합니다.

Server가 기록하는 단계와 Infra가 수집하는 범위는
[AI 파이프라인 관측 가이드](ai-pipeline-observability.md)를 기준으로 맞춥니다.

따라서 `server`의 `aiintegration`에는 Provider SDK나 Prompt Builder를 넣지
않습니다. `workflow`은 Knowledge projection을 읽지만 원본 정의를 수정하지
않습니다.
Expand Down
10 changes: 10 additions & 0 deletions prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
global:
scrape_interval: 5s
evaluation_interval: 5s

scrape_configs:
- job_name: fowoco-server-local
metrics_path: /actuator/prometheus
static_configs:
- targets:
- host.docker.internal:8080
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
import java.util.Objects;
import java.util.UUID;

import static com.fowoco.server.airun.application.AiRunExecutionTelemetry.Phase.ANALYZE;
import static com.fowoco.server.airun.application.AiRunExecutionTelemetry.Stage.ANALYZE_RUNTIME_CALL;
import static com.fowoco.server.airun.application.AiRunExecutionTelemetry.Stage.SLOT_RESOLUTION;

/**
* Continues a validated CONTEXT_REQUIRED result without holding a database transaction open.
* #24 wires this service to a durable AiAttempt implementation.
Expand All @@ -31,18 +35,21 @@ public final class AiAnalysisContinuationService {
private final AiSlotResolutionTransaction slotResolutionTransaction;
private final AiAttemptStarter attemptStarter;
private final AiRuntimeClient runtimeClient;
private final AiRunExecutionTelemetry telemetry;

public AiAnalysisContinuationService(
AiSlotResolutionTransaction slotResolutionTransaction,
AiAttemptStarter attemptStarter,
AiRuntimeClient runtimeClient
AiRuntimeClient runtimeClient,
AiRunExecutionTelemetry telemetry
) {
this.slotResolutionTransaction = Objects.requireNonNull(
slotResolutionTransaction,
"slotResolutionTransaction must not be null"
);
this.attemptStarter = Objects.requireNonNull(attemptStarter, "attemptStarter must not be null");
this.runtimeClient = Objects.requireNonNull(runtimeClient, "runtimeClient must not be null");
this.telemetry = Objects.requireNonNull(telemetry, "telemetry must not be null");
}

public AiAnalysisContinuationResult continueAnalysis(
Expand All @@ -59,10 +66,16 @@ public AiAnalysisContinuationResult continueAnalysis(
Objects.requireNonNull(callContext, "callContext must not be null");
validateContinuation(previousRequest, previousResponse, completedContextRounds);

AiSlotResolution resolution = slotResolutionTransaction.resolve(
companyId,
previousRequest.requiredKnowledgeVersion(),
previousResponse.contextRequirement()
AiSlotResolution resolution = telemetry.measure(
previousRequest.requestId(),
previousRequest.attemptId(),
ANALYZE,
SLOT_RESOLUTION,
() -> slotResolutionTransaction.resolve(
companyId,
previousRequest.requiredKnowledgeVersion(),
previousResponse.contextRequirement()
)
);
validateSameWorker(previousRequest, resolution.worker());

Expand All @@ -88,7 +101,13 @@ public AiAnalysisContinuationResult continueAnalysis(
remainingDeadlineMs,
analyzeInput
);
AiAnalysisResponse response = runtimeClient.analyze(analyzeRequest, callContext);
AiAnalysisResponse response = telemetry.measure(
analyzeRequest.requestId(),
analyzeRequest.attemptId(),
ANALYZE,
ANALYZE_RUNTIME_CALL,
() -> runtimeClient.analyze(analyzeRequest, callContext)
);
return new AiAnalysisContinuationResult(
attemptId,
response,
Expand Down
Loading
Loading