diff --git a/.github/workflows/workers-invocations-chart.yml b/.github/workflows/workers-invocations-chart.yml index 53ae171..c8edc0c 100644 --- a/.github/workflows/workers-invocations-chart.yml +++ b/.github/workflows/workers-invocations-chart.yml @@ -61,6 +61,7 @@ jobs: WORKERS_CHART_DAYS: ${{ inputs.days || '30' }} WORKERS_CHART_START_DATE: ${{ inputs.start_date || vars.WORKERS_CHART_START_DATE || '' }} WORKERS_CHART_CONCURRENCY: ${{ vars.WORKERS_CHART_CONCURRENCY || '4' }} + WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS: ${{ vars.WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS || '7' }} WORKERS_CHART_ROOT_REDIRECT_START: ${{ vars.WORKERS_CHART_ROOT_REDIRECT_START || '2026-05-27T07:24:50.000Z' }} run: npm run update:workers-chart diff --git a/scripts/ops/README.md b/scripts/ops/README.md index 8df3746..032f05d 100644 --- a/scripts/ops/README.md +++ b/scripts/ops/README.md @@ -17,4 +17,6 @@ - `CLOUDFLARE_API_TOKEN` - `CF_WORKER_SCRIPT_NAME` (기본값: `daiso-mcp`) +`GET /`를 R2로 리다이렉트한 뒤 Worker를 우회하는 루트 요청은 Cloudflare zone analytics 보존기간 안에서만 보정합니다. 기본 보정 기간은 `WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS=7`입니다. + 기존 JSON으로 그래프만 다시 렌더링할 때는 Cloudflare 키 대신 `WORKERS_CHART_INPUT_JSON=assets/analytics/workers-invocations.json`을 지정할 수 있습니다. diff --git a/scripts/ops/update-workers-invocations-chart.ts b/scripts/ops/update-workers-invocations-chart.ts index 72cda86..3154e60 100644 --- a/scripts/ops/update-workers-invocations-chart.ts +++ b/scripts/ops/update-workers-invocations-chart.ts @@ -42,6 +42,7 @@ const CHART_START_DATE = process.env.WORKERS_CHART_START_DATE?.trim() || undefin const CHART_DAYS = Number.parseInt(process.env.WORKERS_CHART_DAYS ?? '30', 10); const CHART_CONCURRENCY = Number.parseInt(process.env.WORKERS_CHART_CONCURRENCY ?? '4', 10); const INPUT_JSON_PATH = process.env.WORKERS_CHART_INPUT_JSON; +const ROOT_REQUESTS_RETENTION_DAYS = Number.parseInt(process.env.WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS ?? '7', 10); const ROOT_REDIRECT_START = process.env.WORKERS_CHART_ROOT_REDIRECT_START ? new Date(process.env.WORKERS_CHART_ROOT_REDIRECT_START) : undefined; @@ -62,6 +63,10 @@ if (!Number.isFinite(CHART_DAYS) || CHART_DAYS < 1) { throw new Error('WORKERS_CHART_DAYS는 1 이상의 숫자여야 합니다.'); } +if (!Number.isFinite(ROOT_REQUESTS_RETENTION_DAYS) || ROOT_REQUESTS_RETENTION_DAYS < 1) { + throw new Error('WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS는 1 이상의 숫자여야 합니다.'); +} + if (ROOT_REDIRECT_START && Number.isNaN(ROOT_REDIRECT_START.getTime())) { throw new Error('WORKERS_CHART_ROOT_REDIRECT_START 형식은 ISO datetime 이어야 합니다.'); } @@ -85,6 +90,10 @@ function calculateStartDateFromDays(endDateText, days) { return formatKstDate(new Date(endDate.getTime() - (days - 1) * 86400000)); } +function calculateRetentionStart(now, days) { + return new Date(now.getTime() - days * 86400000); +} + async function readInputPayload(inputPath) { const resolvedPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(REPO_ROOT, inputPath); const payload = JSON.parse(await fs.readFile(resolvedPath, 'utf8')); @@ -297,6 +306,7 @@ async function main() { const endDateExclusive = parseKstDateText(todayKstDate); endDate = formatKstDate(new Date(endDateExclusive.getTime() - 86400000)); startDate = startDate ?? calculateStartDateFromDays(endDate, CHART_DAYS); + const rootRequestsRetentionStart = calculateRetentionStart(renderedAt, ROOT_REQUESTS_RETENTION_DAYS); points = await fetchDailyWorkerInvocations({ accountId: ACCOUNT_ID, @@ -310,6 +320,7 @@ async function main() { rootRedirectHost: ROOT_REDIRECT_HOST, rootRedirectPath: ROOT_REDIRECT_PATH, rootRedirectStart: ROOT_REDIRECT_START, + rootRequestsRetentionStart, concurrency: Number.isFinite(CHART_CONCURRENCY) && CHART_CONCURRENCY > 0 ? CHART_CONCURRENCY : 4, }); @@ -330,7 +341,7 @@ async function main() { aggregation: ROOT_REDIRECT_START ? 'script-level plus redirected root GET' : 'script-level', includedTraffic: ROOT_REDIRECT_START - ? `Worker invocations for ${scriptName} plus redirected GET ${ROOT_REDIRECT_PATH} traffic on ${ROOT_REDIRECT_HOST} after ${ROOT_REDIRECT_START.toISOString()}.` + ? `Worker invocations for ${scriptName} plus redirected GET ${ROOT_REDIRECT_PATH} traffic on ${ROOT_REDIRECT_HOST} where zone analytics retention permits.` : 'All invocations for this Worker script are counted across routes and domains, including GET / on mcp.aka.page.', rootRedirect: ROOT_REDIRECT_START && ZONE_ID @@ -339,6 +350,7 @@ async function main() { host: ROOT_REDIRECT_HOST, path: ROOT_REDIRECT_PATH, start: ROOT_REDIRECT_START.toISOString(), + rootRequestsRetentionDays: ROOT_REQUESTS_RETENTION_DAYS, } : null, timezone: 'Asia/Seoul', diff --git a/scripts/ops/workers-chart-data.ts b/scripts/ops/workers-chart-data.ts index 7d5ec4a..966c59f 100644 --- a/scripts/ops/workers-chart-data.ts +++ b/scripts/ops/workers-chart-data.ts @@ -1,5 +1,7 @@ import { buildKstDateRangeBetween, parseKstDateText } from './workers-chart-helpers.ts'; +const DEFAULT_ROOT_REQUESTS_RETENTION_MS = 7 * 86400000; + const WORKER_INVOCATIONS_QUERY = ` query WorkerInvocations($accountTag: string, $scriptName: string, $start: Time!, $end: Time!) { viewer { @@ -225,6 +227,7 @@ export async function fetchRootGetRequestsForWindow({ * @param {string} [params.rootRedirectHost] * @param {string} [params.rootRedirectPath] * @param {Date} [params.rootRedirectStart] + * @param {Date} [params.rootRequestsRetentionStart] * @param {number} [params.concurrency] * @param {typeof fetch} [params.fetchImpl] * @returns {Promise>} @@ -241,6 +244,7 @@ export async function fetchDailyWorkerInvocations({ rootRedirectHost = 'mcp.aka.page', rootRedirectPath = '/', rootRedirectStart, + rootRequestsRetentionStart = new Date(Date.now() - DEFAULT_ROOT_REQUESTS_RETENTION_MS), concurrency = 4, fetchImpl = fetch, }) { @@ -264,7 +268,9 @@ export async function fetchDailyWorkerInvocations({ end: window.end, fetchImpl, }); - const redirectStart = rootRedirectStart && rootRedirectStart > window.start ? rootRedirectStart : window.start; + const redirectStart = [window.start, rootRedirectStart, rootRequestsRetentionStart] + .filter((date) => date instanceof Date) + .reduce((latest, date) => (date > latest ? date : latest), window.start); const redirectedRootRequests = zoneId && rootRedirectStart && redirectStart < window.end ? await fetchRootGetRequestsForWindow({ diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 9a8f513..f034fce 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -98,6 +98,7 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain('npm run update:workers-chart'); expect(workflow).toContain("default: ''"); expect(workflow).toContain('WORKERS_CHART_CONCURRENCY'); + expect(workflow).toContain('WORKERS_CHART_ROOT_REQUESTS_RETENTION_DAYS'); expect(workflow).toContain('CLOUDFLARE_EMAIL'); expect(workflow).toContain('CLOUDFLARE_GLOBAL_API_KEY'); expect(workflow).toContain('CLOUDFLARE_ZONE_ID'); diff --git a/tests/scripts/workers-chart-data.test.ts b/tests/scripts/workers-chart-data.test.ts index e49113b..b5a13cc 100644 --- a/tests/scripts/workers-chart-data.test.ts +++ b/tests/scripts/workers-chart-data.test.ts @@ -320,6 +320,7 @@ describe('fetchDailyWorkerInvocations', () => { endDateText: '2026-05-27', zoneId: 'zone-id', rootRedirectStart: new Date('2026-05-27T07:24:50.000Z'), + rootRequestsRetentionStart: new Date('2026-05-20T00:00:00.000Z'), fetchImpl: mockFetch, }); @@ -330,6 +331,64 @@ describe('fetchDailyWorkerInvocations', () => { expect(rootGetBody.variables.end).toBe('2026-05-27T15:00:00.000Z'); }); + it('zone analytics 보존기간 밖의 루트 GET 요청 조회는 건너뛴다', async () => { + mockFetch.mockImplementation(async (_input: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (String(body.query).includes('httpRequestsAdaptiveGroups')) { + return new Response( + JSON.stringify({ + data: { + viewer: { + zones: [{ httpRequestsAdaptiveGroups: [{ count: 25 }] }], + }, + }, + }), + ); + } + + const start = String(body.variables.start); + return new Response( + JSON.stringify({ + data: { + viewer: { + accounts: [ + { + workersInvocationsAdaptive: [{ sum: { requests: start.includes('06-03') ? 100 : 200 } }], + }, + ], + }, + }, + }), + ); + }); + + const points = await fetchDailyWorkerInvocations({ + accountId: 'account-id', + apiToken: 'api-token', + scriptName: 'daiso-mcp', + startDateText: '2026-06-04', + endDateText: '2026-06-05', + zoneId: 'zone-id', + rootRedirectStart: new Date('2026-05-27T07:24:50.000Z'), + rootRequestsRetentionStart: new Date('2026-06-04T15:00:00.000Z'), + fetchImpl: mockFetch, + concurrency: 1, + }); + + expect(points).toEqual([ + { date: '2026-06-04', requests: 100 }, + { date: '2026-06-05', requests: 225 }, + ]); + + const rootGetCalls = mockFetch.mock.calls + .map((call) => JSON.parse(String(call[1]?.body))) + .filter((body) => String(body.query).includes('httpRequestsAdaptiveGroups')); + + expect(rootGetCalls).toHaveLength(1); + expect(rootGetCalls[0]?.variables.start).toBe('2026-06-04T15:00:00.000Z'); + expect(rootGetCalls[0]?.variables.end).toBe('2026-06-05T15:00:00.000Z'); + }); + it('지정한 동시성 안에서 날짜별 조회를 병렬 실행하고 원래 날짜 순서를 유지한다', async () => { let active = 0; let maxActive = 0;