8000 feat: 릴레이 호출 절감과 디스크 안전 장애 추적 by hmmhmmhm · Pull Request #202 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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
50 changes: 50 additions & 0 deletions docs/relay-observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 릴레이 캐시 및 장애 추적

## 요청 ID로 찾기

REST/MCP HTTP 응답의 `x-request-id`를 확보한다. Worker 진단 로그의 `requestId`와 맥 릴레이 JSONL에서 같은 값을 찾으면 요청 경계를 연결할 수 있다. 동시에 합쳐진 OY 요청은 `parentRequestId`로 실제 실행을 맡은 요청을 찾는다. 내부 Actions 호출도 같은 ID를 유지한다.

Worker는 모든 REST/MCP 요청 경계와 MCP 도구 결과, 공용 HTTP 유틸리티에서 실패를 기록한다. 성공은 요청 단위 1% 표본이며 요청당 최대 32개 이벤트를 기록한다. 개별 서비스가 자체 fetch를 쓸 경우 공용 HTTP 단계는 없을 수 있지만 REST/MCP 경계와 두 릴레이에는 기록이 남는다. 재시도·기존 결과 반환으로 최종 성공한 요청도 중간 HTTP 오류는 기록될 수 있다. Cloudflare 자체 장애로 코드가 실행되지 않은 경우에는 플랫폼 상태/Analytics를 함께 확인한다.

Cloudflare Workers Observability에서 `event = diagnostic`, `requestId = <ID>`로 조회한다. 로그는 플랫폼에서 보존한다(현재 Free 3일/Paid 7일). 무제한 로컬 복사 작업은 없다. 자동 invocation 로그는 비활성화하고 커스텀 로그만 사용한다. 로그 비용/계정 한도는 Cloudflare 정책의 영향을 받는다.

- https://developers.cloudflare.com/workers/observability/logs/workers-logs/
- https://developers.cloudflare.com/workers/runtime-apis/nodejs/asynclocalstorage/

## 맥 로그 위치와 상한

각 `OY_RELAY_STATE_DIR/logs`, `DTRYX_RELAY_STATE_DIR/logs`에 `서비스-생성시각-uuid.jsonl`이 생성된다. 디렉터리 0700, 새 로그 파일 0600이다.

| 항목 | 제한 |
| --- | --- |
| 파일 하나 | 4 MiB |
| 서비스 하나 합계 | 64 MiB |
| 현재 두 서비스 합계 | 128 MiB |
| 보존 | 생성 시각부터 7일, 용량 초과 시 오래된 파일부터 제거 |
| 디스크 여유 | 기록 후 최소 2 GiB 유지 확인 |
| 기록 대기 | 최대 256개, 각 이벤트 2 KiB 미만 |
| 유휴 정리 | 시작 시 및 1분 heartbeat 시 정리 |

로그 파일만 정리하며 다른 파일은 삭제하지 않는다. 새 프로세스도 기존 로그를 합산한다. 디스크 부족/ENOSPC/기록 오류/큐 초과에서는 이벤트를 버리고 요청 처리 자체는 계속한다. 여유가 회복되면 자동 기록 재개한다. 다른 프로그램의 디스크 사용까지 통제하는 기능은 아니다. 프로세스 강제 종료나 시스템 장애 직전의 대기 이벤트는 유실될 수 있다.

인증된 health의 `logging`에서 `queued`, `written`, `dropped`, `errors`, `lowSpace`를 확인한다. 이 카운터는 프로세스 재시작 시 초기화된다. 시작과 heartbeat에 quota 잔여량이 기록되고 `quota/consumed`는 OY 실제 실행 전에 차감된 호출을 의미한다. 집계는 JSONL의 UTC time, operation, stage, outcome을 사용한다. 보존 기간/용량/드롭을 함께 보고 숫자를 해석한다.

기록하는 값은 고정 분류, 요청 ID, 작업 종류, 단계, 상태, 소요 시간, 캐시 결과, 제한 사유, quota 잔여량이다. 검색어, 위치 좌표, 요청/응답 본문, 인증 헤더, IP, 임의 예외 메시지는 넣지 않는다. 조회 예:

```sh
rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_RELAY_STATE_DIR/logs"
```

## 원인 분류

- Worker 실패 로그는 있고 같은 ID의 릴레이 로그가 없음: relay 설정·네트워크·Cloudflare Access/Tunnel을 확인한다. 로컬 `logging.dropped/errors/lowSpace`도 먼저 점검한다.
- `quota` 단계와 429: health `quota.blockedBy`로 minute/daily를 구분하고 resetAt/Retry-After 이후 재시도한다. OY 응답은 `x-relay-quota-reason`도 제공한다.
- `queue/expired` 또는 busy: 큐/동시 요청 과부하와 클라이언트 취소를 확인한다.
- `upstream/error`: 서비스 원본/브라우저 실패. OY `lifecycle`의 launch-failed/startup-failed/request-failed/page-crash/rotation 원인과 비교한다.
- `process/startup-failed` 또는 heartbeat 단절: LaunchAgent 상태와 프로세스 시작 환경을 점검한다. cloudflared는 자체 플랫폼 상태도 확인한다.

## OY 캐시

인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다.

캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. quota는 기존 분당30/UTC 일일3000을 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다.
13 changes: 13 additions & 0 deletions docs/superpowers/plans/2026-09-29-relay-observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Relay Observability Implementation Plan

> **For agentic workers:** Use superpowers:subagent-driven-development for isolated implementation and review.

**Goal:** OY 반복 upstream 호출을 절감하고 디스크 안전 추적을 배포한다.
**Architecture:** bounded relay cache + 공통 로컬 JSONL logger + Worker request context.
**Tech Stack:** TypeScript, Node24, Hono, Cloudflare Workers, Vitest.

- [x] Task 1: scripts/relay/cache.ts, quota.ts, oliveyoung.ts와 tests/relay 확장. 동일 요청 3회 차감1, 동시합치기, TTL/용량 퇴출, 오류미저장, abort/queue/권한 회귀를 먼저 재현한다. quota 함수의 boolean 계약 유지하며 status()로 분/일 원인과 Retry-After를 얻는다.
- [x] Task 2: scripts/relay/logging.ts + tests/relay. 고정 schema append(event), flush(), status() API. 4MiB rotation/64MiB retention/7일/2GiB reserve/256 pending/2KiB event. statfs/IO는 주입해서 디스크부족과 ENOSPC, restart 파일 정리를 검증한다. start.ts,dtryx-start.ts 연결은 root가 수행한다.
- [x] Task 3: src/utils/diagnostics.ts, src/index.ts, core/registry.ts, utils/http.ts, relay transports. request-scoped context에서 경계 기록 및 ID 전달. src/api/routes/oliveyoungRoutes.ts 키 정규화. Worker config logs 설정. 테스트는 개인정보 미노출·동시 context 격리·오류 기록·기존 응답 계약 보존.
- [x] Task 4: 루트에서 start/lifecycle/Dtryx integration, 운영 문서, 전체 npm run check / build / test:coverage, 독립 spec/quality review 및 수정.
- [ ] Task 5: PR 생성 및 CI 확인 후 병합. Mac runtime 복사/원자적 교체·LaunchAgent 재시작, Worker 배포 확인, REST/MCP 실조회 및 로그 상관/권한/크기/캐시절감 확인. 운영상태 기록.
14 changes: 14 additions & 0 deletions docs/superpowers/specs/2026-09-29-relay-observability-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 릴레이 호출 절감 및 추적 설계

사용자가 2026-09-29 승인한 공통 캐시/중복 합치기와 디스크 안전 로깅을 구현한다.

- OY 인증/검증 후 정규 payload 키로 캐시한다. 상품 5분, 상품 ID 10분, 매장 5분, 재고 1분. 최대 256항목 및 직렬화 16MiB. 성공만 저장한다. 동일 진행 요청을 합치며 기존 queue/body/timeout/인증 제한을 유지한다. 캐시 적중은 quota 미차감.
- quota 분/일 차단을 구분하고 Retry-After와 현재 잔여/초기화 시각을 인증 health에서 제공한다. 과거 집계는 로그에서 보존한다.
- 두 로컬 릴레이에 공통 JSONL 로깅. 서비스별 64MiB(합계128MiB), 파일4MiB, 7일. 디스크 가용2GiB 미만이면 자체 오래된 로그 정리 후 기록을 중단한다. 다른 파일은 삭제하지 않는다. 재검사로 여유 회복 시 자동 재개. 큐는256개 이하, 이벤트2KiB 이하. logger 실패가 서비스 실패로 번지지 않는다.
- 로그 필드는 고정 allowlist: 시각/요청ID/상위ID/서비스/operation/단계/상태/소요시간/캐시결과/차단사유. URL query, 요청/응답 본문, 검색어, 좌표, 토큰, 임의 에러 메시지 제외. 로그0700/0600. health에 로그드롭/기록실패/디스크중단 상태를 노출한다.
- Worker 요청 컨텍스트별 ID를 응답 및 relay 요청에 전달. 모든 REST/MCP 서비스에 경계 이벤트를 적용하고 실패는 모두, 성공은 샘플링. Cloudflare managed logs 사용, 자동 invocation 로그는 꺼 민감 query가 기록되지 않게 한다. CLI stdout은 MCP 프로토콜 보호를 위해 사용하지 않는다.
- REST OY 캐시 키는 허용 query 및 기본값 정규화. 의도적 health fresh 파라미터는 보존한다.
- 릴레이 lifecycle 시작/회전/실패와 프로세스 시작/종료 기록. 터널은 기존 cloudflared 상태와 상관관계로 조사하며 무제한 stdout 파일을 만들지 않는다.
- 실패/만료/중복/용량초과/디스크부족/재시작 보존 및 민감값 미기록 테스트. 전체 check/build/100% coverage 후 PR 검토·병합, Worker 및 Mac 두 런타임 배포와 실조회 검증.

한도 증가는 하지 않는다. fresh cache가 재고를 최대1분 지연시킬 수 있다. 디스크부족에서는 서비스 가용성과 디스크 보존을 위해 파일 로그를 버리며 health로 누락을 알린다.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "daiso",
"version": "1.2.5",
"version": "1.2.6",
"description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
"main": "dist/index.js",
"bin": {
Expand Down
46 changes: 46 additions & 0 deletions scripts/relay/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/** 검증된 평면 입력용 키와 바이트·항목 상한을 가진 TTL LRU 캐시. */
export function canonicalKey(operation: string, body: Record<string, unknown>): string {
return JSON.stringify([
operation,
Object.keys(body)
.sort()
.map((key) => [key, body[key]]),
]);
}
export function createResponseCache(maxEntries = 256, maxBytes = 16 * 1024 * 1024, now = Date.now) {
const entries = new Map<string, { value: string; bytes: number; expires: number }>();
let bytes = 0;
const remove = (key: string) => {
bytes -= entries.get(key)!.bytes;
entries.delete(key);
};
const prune = () => {
const time = now();
for (const [key, entry] of entries) if (entry.expires <= time) remove(key);
};
return {
get(key: string): string | undefined {
prune();
const entry = entries.get(key);
if (!entry) return undefined;
entries.delete(key);
entries.set(key, entry);
return entry.value;
},
set(key: string, value: string, ttl: number) {
prune();
if (entries.has(key)) remove(key);
const size = Buffer.byteLength(key) + Buffer.byteLength(value);
if (size > maxBytes) return;
while (entries.size >= maxEntries || bytes + size > maxBytes) {
remove(entries.keys().next().value!);
}
entries.set(key, { value, bytes: size, expires: now() + ttl });
bytes += size;
},
stats() {
prune();
return { entries: entries.size, bytes, maxEntries, maxBytes };
},
};
}
37 changes: 33 additions & 4 deletions scripts/relay/dtryx-start.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createRelayLogger } from './logging.js';
import { observeRelay } from './observed.js';
/** 브라우저 없이 별도 프로세스로 디트릭스 중계를 실행합니다. */
import { createServer } from 'node:http';
import { Readable } from 'node:stream';
Expand All @@ -7,6 +9,7 @@ import { pathToFileURL } from 'node:url';
import { createFileQuota } from './quota.js';
import { createDtryxRelay } from './dtryx.js';

let logger: ReturnType<typeof createRelayLogger> | undefined;
async function main() {
const token = process.env.DTRYX_RELAY_TOKEN;
const stateDir = process.env.DTRYX_RELAY_STATE_DIR;
Expand All @@ -15,9 +18,28 @@ async function main() {
throw new Error('Invalid relay configuration');
}
await mkdir(stateDir, { recursive: true, mode: 0o700 });
const handler = createDtryxRelay(token, {
takeQuota: await createFileQuota(join(stateDir, 'quota.json')),
});
logger = createRelayLogger(join(stateDir, 'logs'), { service: 'dtryx' });
const log = logger;
log.append({ stage: 'process', outcome: 'starting' });
const takeQuota = await createFileQuota(join(stateDir, 'quota.json'));
log.append({ stage: 'quota', outcome: 'snapshot', ...takeQuota.status() });
const heartbeat = setInterval(
() => log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }),
60000,
);
heartbeat.unref();
const relay = createDtryxRelay(token, { takeQuota, onEvent: log.append });
const handler = observeRelay(async (request) => {
const response = await relay(request);
if (response.ok && new URL(request.url).pathname === '/v1/dtryx/health') {
return Response.json({
...(await response.json()),
quota: takeQuota.status(),
logging: log.status(),
});
}
return response;
}, log.append);
const server = createServer(async (req, res) => {
const controller = new AbortController();
res.once('close', () => controller.abort());
Expand All @@ -32,12 +54,14 @@ async function main() {
} as RequestInit & { duplex: 'half' }),
);
res.writeHead(response.status, {
...Object.fromEntries(response.headers),
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
Connection: 'close',
});
res.end(await response.text());
} catch {
log.append({ stage: 'server', outcome: 'error', status: 500 });
res.writeHead(500, { Connection: 'close' });
res.end('{"error":"Relay failure"}');
}
Expand All @@ -51,14 +75,19 @@ async function main() {
});
console.log(`Dtryx relay ready on 127.0.0.1:${port}`);
const close = () => {
clearInterval(heartbeat);
log.append({ stage: 'process', outcome: 'stopping' });
void log.flush();
server.close();
server.closeAllConnections();
};
process.once('SIGTERM', close);
process.once('SIGINT', close);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(() => {
main().catch(async () => {
logger?.append({ stage: 'process', outcome: 'startup-failed' });
await logger?.flush();
console.error('디트릭스 릴레이 시작 실패: 토큰, 포트 및 상태 경로를 확인하세요.');
process.exitCode = 1;
});
Expand Down
33 changes: 32 additions & 1 deletion scripts/relay/dtryx.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** 고정 공개 API만 허용하는 디트릭스 전용 중계입니다. */
import { timingSafeEqual } from 'node:crypto';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { DTRYX_API } from '../../src/services/dtryx/api.js';

type Route = 'timetable' | 'play-dates' | 'movies';
Expand All @@ -11,6 +11,14 @@ interface Params {
interface Options {
takeQuota: () => Promise<boolean>;
fetcher?: typeof fetch;
onEvent?: (event: {
stage: string;
operation: string;
requestId: string;
outcome: string;
status: number;
durati 10BC8 onMs: number;
}) => void | Promise<void>;
}
class RelayError extends Error {
constructor(readonly status: number) {
Expand Down Expand Up @@ -195,18 +203,25 @@ export function createDtryxRelay(token: string, options: Options) {
abort();
}, 15000);
let acquired = false;
let stage = 'body';
let resultStatus = 200;
const suppliedId = request.headers.get('x-request-id');
const requestId =
suppliedId && /^[A-Za-z0-9_-]{1,64}$/.test(suppliedId) ? suppliedId : randomUUID();
try {
const p = validate(
await readJson(request.body, 16384, controller.signal, 400),
name as Route,
);
stage = 'queue';
await acquire(controller.signal);
acquired = true;
if (performance.now() >= deadline) {
expired = true;
abort();
}
controller.signal.throwIfAborted();
stage = 'quota';
if (
!(await abortable(
options.takeQuota().catch(() => {
Expand All @@ -217,6 +232,7 @@ export function createDtryxRelay(token: string, options: Options) {
)
throw new RelayError(429);
controller.signal.throwIfAborted();
stage = 'upstream';
return reply(200, await upstream(name as Route, p, controller.signal, fetcher));
} catch (error) {
const status = expired
Expand All @@ -226,8 +242,23 @@ export function createDtryxRelay(token: string, options: Options) {
: error instanceof RelayError
? error.status
: 502;
resultStatus = status;
return reply(status, { error: 'Relay request failed' });
} finally {
try {
void Promise.resolve(
options.onEvent?.({
stage,
operation: name,
requestId,
outcome: resultStatus === 200 ? 'ok' : 'error',
status: resultStatus,
durationMs: performance.now() - deadline + 15000,
}),
).catch(() => undefined);
} catch {
/* 관측 실패는 요청에 영향을 주지 않습니다. */
}
clearTimeout(timer);
request.signal.removeEventListener('abort', abort);
controller.abort();
Expand Down
31 changes: 28 additions & 3 deletions scripts/relay/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ export async function bounded<T>(work: Promise<T>, ms: number): Promise<T> {
clearTimeout(timer!);
}
}
export function createBrowserLifecycle(launch: () => Promise<BrowserOwner>) {
export function createBrowserLifecycle(
launch: () => Promise<BrowserOwner>,
onEvent?: (reason: string) => void | Promise<void>,
) {
const emit = (reason: string) => {
try {
void Promise.resolve(onEvent?.(reason)).catch(() => undefined);
} catch {
/* 관측 실패는 브라우저 회수에 영향을 주지 않습니다. */
}
};
let owner: BrowserOwner | undefined;
let page: Page | undefined;
let context: BrowserContext | undefined;
Expand All @@ -40,6 +50,7 @@ export function createBrowserLifecycle(launch: () => Promise<BrowserOwner>) {
let replacementPending = false;
const retire = (why: string) => {
reason = why;
emit(why);
if (timer) clearInterval(timer);
timer = undefined;
const previous = owner;
Expand Down Expand Up @@ -69,7 +80,12 @@ export function createBrowserLifecycle(launch: () => Promise<BrowserOwner>) {
rotations++;
}
if (owner) return;
owner = await launch();
try {
owner = await launch();
} catch (error) {
emit('launch-failed');
throw error;
}
born = Date.now();
calls = 0;
if (closed) {
Expand Down Expand Up @@ -97,6 +113,7 @@ export function createBrowserLifecycle(launch: () => Promise<BrowserOwner>) {
);
await bounded(page.waitForURL('**/store/**', { timeout: 30000 }), 32000);
if (closed || owner !== sessionOwner) throw new Error('Browser lifecycle unavailable');
emit('ready');
timer = setInterval(() => {
if (!active && Date.now() - born >= 30 * 60 * 1000) {
rotations++;
Expand Down Expand Up @@ -166,7 +183,15 @@ export function createBrowserLifecycle(launch: () => Promise<BrowserOwner>) {
await cleanup;
},
status: () => ({
state: failed ? 'failed' : closed ? 'closed' : initializing ? 'starting' : owner ? 'ready' : 'idle',
state: failed
? 'failed'
: closed
? 'closed'
: initializing
? 'starting'
: owner
? 'ready'
: 'idle',
active,
calls,
totalCalls,
Expand Down
Loading
Loading
0