diff --git a/.changeset/yellow-symbols-float.md b/.changeset/yellow-symbols-float.md new file mode 100644 index 0000000..0403e0d --- /dev/null +++ b/.changeset/yellow-symbols-float.md @@ -0,0 +1,9 @@ +--- +"@naverpay/prometheus-core": major +"@naverpay/prometheus-hono": major +"@naverpay/prometheus-koa": major +--- + +add @naverpay/prometheus-hono + +PR: [add @naverpay/prometheus-hono](https://github.com/NaverPayDev/prometheus/pull/7) diff --git a/README.md b/README.md index 9fe1e73..1f632d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @naverpay/prometheus -Prometheus 메트릭 수집 및 내보내기를 위한 TypeScript 라이브러리 모음입니다. PM2 클러스터링 환경에서의 메트릭 수집을 지원하며, Koa 및 Next.js 프레임워크와의 통합을 제공합니다. +Prometheus 메트릭 수집 및 내보내기를 위한 TypeScript 라이브러리 모음입니다. PM2 클러스터링 환경에서의 메트릭 수집을 지원하며, Koa, Hono 및 Next.js 프레임워크와의 통합을 제공합니다. ## 패키지 구조 @@ -16,7 +16,7 @@ Prometheus 메트릭 수집 및 내보내기를 위한 TypeScript 라이브러 - PM2 프로세스 간 메트릭 수집 및 집계 - Next.js 라우트 정규화 - HTTP 상태 코드 그룹화 -- 기본 Node.js 메트릭 수집 +- 기본 Node.js 메트릭 수집 (기본값: true) ### 📦 [@naverpay/prometheus-koa](./packages/koa) @@ -29,6 +29,17 @@ Koa.js 프레임워크용 Prometheus 미들웨어 및 라우터를 제공합니 - API 트레이싱 미들웨어 - 요청 경로 정규화 지원 +### 📦 [@naverpay/prometheus-hono](./packages/hono) + +Hono 프레임워크용 Prometheus 미들웨어 및 라우터를 제공합니다. + +**주요 기능:** + +- HTTP 요청 메트릭 수집 미들웨어 +- 메트릭 엔드포인트 라우터 +- API 트레이싱 미들웨어 +- 요청 경로 정규화 지원 + ### 📦 [@naverpay/prometheus-next](./packages/next) Next.js 애플리케이션용 통합 서버 솔루션을 제공합니다. @@ -50,6 +61,9 @@ npm install @naverpay/prometheus-core # Koa 사용 시 npm install @naverpay/prometheus-koa +# Hono 사용 시 +npm install @naverpay/prometheus-hono + # Next.js 사용 시 npm install @naverpay/prometheus-next ``` diff --git a/packages/core/README.md b/packages/core/README.md index 4d5e8ff..8d6fec0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -107,6 +107,20 @@ PM2 연결을 해제하고 메트릭 레지스트리를 초기화합니다. await pm2Connector.disconnect() ``` +### Standalone 메트릭 + +#### `getStandaloneMetrics()` + +PM2 없이 단일 프로세스에서 메트릭을 수집합니다. + +```typescript +import { getStandaloneMetrics } from '@naverpay/prometheus-core' + +const { metrics, contentType } = await getStandaloneMetrics() +console.log(metrics) // Prometheus 형식의 메트릭 문자열 +console.log(contentType) // 'text/plain; version=0.0.4; charset=utf-8' +``` + ### Next.js 유틸리티 #### `getNextRoutesManifest()` @@ -268,8 +282,10 @@ async function handleRequest(req, res) { ```typescript interface CommonPrometheusExporterOptions { - /** PM2 클러스터링 지원 활성화 여부 */ - pm2: boolean + /** 메트릭 수집 활성화 여부 (기본값: true) */ + enabled?: boolean + /** PM2 클러스터링 지원 활성화 여부 (기본값: false) */ + pm2?: boolean /** Next.js 라우트 정규화 활성화 여부 */ nextjs?: boolean /** 메트릭 엔드포인트 경로 */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5281537..4202267 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from './constants' export * from './histogram' export * from './metrics/collect' export * from './metrics/pm2-connector' +export * from './metrics/standalone' export * from './metrics/util' export * from './pm2/messages' export * from './pm2/promisify' diff --git a/packages/core/src/metrics/standalone.ts b/packages/core/src/metrics/standalone.ts new file mode 100644 index 0000000..c170fe9 --- /dev/null +++ b/packages/core/src/metrics/standalone.ts @@ -0,0 +1,10 @@ +import promClient from 'prom-client' + +/** + * Gets metrics from prom-client registry for standalone (non-PM2) mode + * @returns Promise resolving to metrics string and content type + */ +export async function getStandaloneMetrics() { + const metrics = await promClient.register.metrics() + return {metrics, contentType: promClient.register.contentType} +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5875a9d..72e15ee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,7 +1,9 @@ /** Common configuration options for Prometheus exporters */ export interface CommonPrometheusExporterOptions { - /** Whether to enable PM2 clustering support */ - pm2: boolean + /** Whether to enable metrics collection (default: true) */ + enabled?: boolean + /** Whether to enable PM2 clustering support for aggregating metrics across workers */ + pm2?: boolean /** Whether to enable Next.js route normalization */ nextjs?: boolean /** Custom path for metrics endpoint */ diff --git a/packages/hono/README.md b/packages/hono/README.md new file mode 100644 index 0000000..937972f --- /dev/null +++ b/packages/hono/README.md @@ -0,0 +1,290 @@ +# @naverpay/prometheus-hono + +Hono 애플리케이션을 위한 Prometheus 메트릭 수집 미들웨어 및 라우터입니다. + +## 설치 + +```bash +npm install @naverpay/prometheus-hono +``` + +## 주요 기능 + +### HTTP 메트릭 수집 + +- **요청 지속시간**: 모든 HTTP 요청의 응답 시간 측정 +- **자동 라벨링**: 상태 코드, HTTP 메서드, 경로 자동 수집 +- **경로 정규화**: 동적 라우트를 패턴으로 그룹화 + +### 스마트 필터링 + +- **자동 바이패스**: 헬스체크, 메트릭 엔드포인트 등 자동 제외 +- **커스텀 필터**: 사용자 정의 바이패스 로직 지원 +- **Next.js 지원**: Next.js 정적 파일 및 라우트 자동 처리 + +### API 트레이싱 + +- **전용 API 메트릭**: API 엔드포인트 전용 세밀한 모니터링 +- **커스텀 상태 처리**: 비즈니스 로직 기반 상태 분류 +- **404 핸들링**: API 미매칭 요청 자동 처리 + +### 유연한 설정 + +- **경로 정규화**: 커스텀 경로 그룹화 함수 +- **상태 코드 포맷팅**: 사용자 정의 상태 분류 +- **PM2 통합**: 클러스터 환경에서 메트릭 집계 +- **Standalone 모드**: PM2 없이 단일 프로세스에서도 메트릭 수집 + +## 빠른 시작 + +### 기본 설정 (Standalone 모드) + +```typescript +import { Hono } from 'hono' +import { createHonoPrometheusExporter } from '@naverpay/prometheus-hono' + +const app = new Hono() + +// 단일 프로세스 환경 (Docker, K8s 등) +const { middleware, router, disconnect } = await createHonoPrometheusExporter({ + metricsPath: '/metrics', + collectDefaultMetrics: true, +}) + +// 미들웨어 등록 +app.use('*', middleware) + +// 다른 라우터들... +app.get('/', (c) => c.text('Hello!')) + +// 메트릭 라우터 +app.route('/', router) + +export default app +``` + +### PM2 클러스터 모드 + +```typescript +const { middleware, router, disconnect } = await createHonoPrometheusExporter({ + pm2: true, // PM2 클러스터 메트릭 집계 활성화 + metricsPath: '/metrics', + collectDefaultMetrics: true, +}) + +app.use('*', middleware) +app.route('/', router) + +// 서버 종료 시 정리 +process.on('SIGTERM', async () => { + await disconnect() +}) +``` + +### 메트릭 수집 비활성화 (개발 환경) + +```typescript +const { middleware, router } = await createHonoPrometheusExporter({ + enabled: process.env.NODE_ENV === 'production', + metricsPath: '/metrics', +}) + +// enabled: false일 경우 noop 미들웨어/라우터 반환 +app.use('*', middleware) +app.route('/', router) +``` + +## API 참조 + +### `createHonoPrometheusExporter(options)` + +Hono Prometheus 익스포터를 생성합니다. + +#### 옵션 + +```typescript +interface HonoPrometheusExporterOptions { + /** 메트릭 수집 활성화 여부 (기본값: true) */ + enabled?: boolean + /** PM2 클러스터링 지원 (기본값: false) */ + pm2?: boolean + /** Next.js 라우트 정규화 활성화 */ + nextjs?: boolean + /** 메트릭 엔드포인트 경로 */ + metricsPath?: string + /** 기본 Node.js 메트릭 수집 (기본값: true) */ + collectDefaultMetrics?: boolean + /** 요청 바이패스 함수 */ + bypass?: (context: Context) => boolean + /** 경로 정규화 함수 */ + normalizePath?: (context: Context) => string + /** 상태 코드 포맷팅 함수 */ + formatStatusCode?: (context: Context) => string +} +``` + +#### 반환값 + +```typescript +{ + /** 메트릭 수집 미들웨어 */ + middleware: MiddlewareHandler + /** 메트릭 엔드포인트 라우터 (Hono 앱) */ + router: Hono + /** 연결 해제 함수 */ + disconnect: () => Promise +} +``` + +### API 트레이싱 + +#### `createHonoApiTraceMiddleware(options?)` + +API 전용 트레이싱 미들웨어를 생성합니다. + +```typescript +import { createHonoApiTraceMiddleware } from '@naverpay/prometheus-hono' + +const { startApiTrace, writeApiTrace, endApiTrace } = createHonoApiTraceMiddleware({ + normalizePath: (context) => { + const url = new URL(context.req.url) + return url.pathname.replace(/\/api\/v\d+/, '/api/v*') + } +}) + +// API 라우터에서 사용 +const api = new Hono() + +// 모든 API 요청에 트레이싱 시작 +api.use('*', startApiTrace) + +// API 라우트들... +api.get('/users/:id', async (c) => { + const user = await getUser(c.req.param('id')) + writeApiTrace(c) + return c.json(user) +}) + +// 404 처리 (매칭되지 않은 API 요청) +api.all('*', (c) => endApiTrace(c)) +``` + +### 라우터 유틸리티 + +#### `createNormalizedHonoRouterPath(app, prefix?)` + +Hono 앱의 경로를 정규화하는 함수를 생성합니다. + +```typescript +import { Hono } from 'hono' +import { createNormalizedHonoRouterPath } from '@naverpay/prometheus-hono' + +const app = new Hono() +app.get('/users/:id', handler) +app.get('/posts/:id/comments', handler) + +const normalizeUrl = createNormalizedHonoRouterPath(app) + +console.log(normalizeUrl('/users/123')) // '/users/:id' +console.log(normalizeUrl('/posts/456/comments')) // '/posts/:id/comments' +``` + +## 고급 설정 + +### 커스텀 바이패스 + +```typescript +const { middleware } = await createHonoPrometheusExporter({ + bypass: (context) => { + // 특정 헤더가 있는 요청 제외 + if (context.req.header('X-Health-Check')) return true + + // 특정 User-Agent 제외 + if (context.req.header('User-Agent')?.includes('monitoring')) return true + + return false + } +}) +``` + +### 동적 경로 정규화 + +```typescript +const { middleware } = await createHonoPrometheusExporter({ + normalizePath: (context) => { + const url = new URL(context.req.url) + let path = url.pathname + + // UUID 패턴을 :id로 변경 + path = path.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id') + + // 숫자 ID를 :id로 변경 + path = path.replace(/\/\d+/g, '/:id') + + return path + } +}) +``` + +### 비즈니스 로직 기반 상태 분류 + +```typescript +const { middleware } = await createHonoPrometheusExporter({ + formatStatusCode: (context) => { + const status = context.res.status + if (status >= 500) return 'system_error' + if (status >= 400) return 'client_error' + if (status >= 300) return 'redirect' + if (status === 201) return 'created' + if (status === 204) return 'no_content' + return 'success' + } +}) +``` + +## 수집되는 메트릭 + +### HTTP 요청 메트릭 (`http_request_duration_seconds`) + +모든 HTTP 요청의 응답 시간을 히스토그램으로 수집합니다. + +**라벨:** + +- `status_code`: HTTP 상태 코드 (그룹화됨) +- `method`: HTTP 메서드 (GET, POST, etc.) +- `path`: 정규화된 요청 경로 + +### API 요청 메트릭 (`http_api_request_duration_seconds`) + +API 트레이싱 미들웨어가 활성화된 경우 수집됩니다. + +**라벨:** + +- `path`: 정규화된 API 경로 +- `status`: 응답 상태 +- `method`: HTTP 메서드 + +### 서비스 상태 메트릭 (`up`) + +서비스 가용성을 나타내는 게이지 메트릭입니다. + +- `1`: 서비스 실행 중 +- `0`: 서비스 중단 + +## 모드 비교 + +| 모드 | `enabled` | `pm2` | 사용 환경 | +|------|-----------|-------|-----------| +| 비활성화 | `false` | - | 개발 환경, 메트릭 불필요 시 | +| Standalone | `true` | `false` | Docker, K8s, 단일 프로세스 | +| PM2 클러스터 | `true` | `true` | PM2 클러스터 환경 | + +## 요구 사항 + +- Node.js 18.0.0 이상 +- TypeScript 4.5 이상 +- Hono 4.0.0 이상 + +## 라이센스 + +MIT License diff --git a/packages/hono/package.json b/packages/hono/package.json new file mode 100644 index 0000000..c54c4bc --- /dev/null +++ b/packages/hono/package.json @@ -0,0 +1,53 @@ +{ + "name": "@naverpay/prometheus-hono", + "version": "1.0.0", + "description": "Hono middleware for Prometheus metrics integration.", + "keywords": [ + "hono", + "prometheus", + "prometheus-exporter", + "prometheus-hono", + "prometheus-middleware" + ], + "author": "oneweek-lee", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.mjs", + "types": "./dist/esm/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm clean && vite build", + "clean": "rm -rf dist" + }, + "dependencies": { + "@naverpay/es-http-status-codes": "^0.0.4", + "@naverpay/prometheus-core": "workspace:*" + }, + "devDependencies": { + "@naverpay/pite": "catalog:", + "@naverpay/tsconfig": "workspace:*", + "@types/node": "catalog:", + "hono": "^4.6.0", + "vite": "catalog:" + }, + "peerDependencies": { + "hono": ">=4.0.0" + }, + "packageManager": "pnpm@10.6.5" +} diff --git a/packages/hono/src/exporter.ts b/packages/hono/src/exporter.ts new file mode 100644 index 0000000..bced9fa --- /dev/null +++ b/packages/hono/src/exporter.ts @@ -0,0 +1,79 @@ +import { + BUCKETS, + DEFAULT_METRICS_LABELS, + DEFAULT_METRICS_NAMES, + DEFAULT_METRICS_TYPE, + enableCollectDefaultMetrics, + registerGaugeUp, + pm2Connector, + registerHistogram, +} from '@naverpay/prometheus-core' + +import {getHonoMetricsMiddleware} from './middleware/metrics' +import {getHonoNoopMiddleware} from './middleware/noop' +import {getHonoMetricsRouter} from './router/metrics' +import {getNoopRouter} from './router/noop' +import {getHonoStandaloneMetricsRouter} from './router/standalone' + +import type {HonoPrometheusExporterOptions} from './types' + +async function noop() { + // No operation +} + +/** + * Creates a Hono Prometheus exporter with middleware and router + * @param options - Configuration options for the exporter + * @returns Object containing router, middleware, and disconnect function + */ +export async function createHonoPrometheusExporter({ + enabled = true, + pm2 = false, + nextjs = true, + metricsPath, + collectDefaultMetrics = true, + bypass, + normalizePath, + formatStatusCode, +}: HonoPrometheusExporterOptions) { + // Disabled: return noop + if (!enabled) { + return { + router: getNoopRouter({metricsPath}), + middleware: getHonoNoopMiddleware(), + disconnect: noop, + } + } + + // PM2 cluster mode: connect to PM2 + if (pm2) { + await pm2Connector.connect() + } + + // Register default metrics + if (collectDefaultMetrics) { + enableCollectDefaultMetrics() + } + + // Register HTTP request histogram + registerHistogram( + DEFAULT_METRICS_TYPE.HTTP_REQUEST, + DEFAULT_METRICS_NAMES.http_request, + DEFAULT_METRICS_LABELS.http_request, + BUCKETS, + ) + + registerGaugeUp() + + const middleware = getHonoMetricsMiddleware({nextjs, bypass, normalizePath, formatStatusCode}) + + // PM2 mode: aggregated metrics from all workers + // Standalone mode: single process metrics + const router = pm2 ? getHonoMetricsRouter({metricsPath}) : getHonoStandaloneMetricsRouter({metricsPath}) + + return { + router, + middleware, + disconnect: pm2 ? pm2Connector.disconnect : noop, + } +} diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts new file mode 100644 index 0000000..bd41bb1 --- /dev/null +++ b/packages/hono/src/index.ts @@ -0,0 +1,4 @@ +export * from './exporter' +export * from './types' +export * from './middleware/api-trace' +export * from './router/utils' diff --git a/packages/hono/src/middleware/api-trace.ts b/packages/hono/src/middleware/api-trace.ts new file mode 100644 index 0000000..b675d95 --- /dev/null +++ b/packages/hono/src/middleware/api-trace.ts @@ -0,0 +1,55 @@ +import {HttpStatusCodes} from '@naverpay/es-http-status-codes' +import { + BUCKETS, + DEFAULT_METRICS_LABELS, + DEFAULT_METRICS_NAMES, + DEFAULT_METRICS_TYPE, + endTraceHistogram, + registerHistogram, + startTraceHistogram, +} from '@naverpay/prometheus-core' + +import type {Context, MiddlewareHandler} from 'hono' + +type TraceMetrics = ReturnType + +declare module 'hono' { + interface ContextVariableMap { + traceMetrics?: TraceMetrics + } +} + +function writeApiTrace(context: Context) { + const traceMetrics = context.get('traceMetrics') + if (traceMetrics) { + endTraceHistogram(traceMetrics, {status: context.res.status}) + } +} + +function endApiTrace(context: Context) { + return context.text('Not Found', HttpStatusCodes.NOT_FOUND) +} + +/** + * Creates middleware for tracing API requests with Prometheus metrics + * @param parameters - Configuration options + * @returns Object containing middleware functions for API tracing + */ +export function createHonoApiTraceMiddleware(parameters?: {normalizePath?: (context: Context) => string}) { + registerHistogram( + DEFAULT_METRICS_TYPE.HTTP_API_REQUEST, + DEFAULT_METRICS_NAMES.http_api_request, + DEFAULT_METRICS_LABELS.http_api_request, + BUCKETS, + ) + + const startApiTrace: MiddlewareHandler = (context, next) => { + const url = new URL(context.req.url) + const path = parameters?.normalizePath?.(context) || url.pathname + const method = context.req.method + context.set('traceMetrics', startTraceHistogram(DEFAULT_METRICS_TYPE.HTTP_API_REQUEST, {path, method})) + return next() + } + + return {startApiTrace, writeApiTrace, endApiTrace} +} diff --git a/packages/hono/src/middleware/metrics.ts b/packages/hono/src/middleware/metrics.ts new file mode 100644 index 0000000..db89e08 --- /dev/null +++ b/packages/hono/src/middleware/metrics.ts @@ -0,0 +1,50 @@ +import { + createNextRoutesUrlGroup, + DEFAULT_METRICS_TYPE, + getStatusCodeGroup, + isBypassPath, + startTraceHistogram, +} from '@naverpay/prometheus-core' + +import type {HonoPrometheusExporterOptions} from '../types' +import type {PromClientLabelValues} from '@naverpay/prometheus-core' +import type {Context, MiddlewareHandler} from 'hono' + +/** + * Creates Hono middleware for collecting HTTP request metrics + * @param options - Configuration options for metrics collection + * @returns Hono middleware function + */ +export function getHonoMetricsMiddleware({ + nextjs, + bypass, + normalizePath, + formatStatusCode, +}: Pick): MiddlewareHandler { + const normalizeNextRoutesPath = nextjs ? createNextRoutesUrlGroup() : undefined + + const extendedNormalizePath = (context: Context) => { + const url = new URL(context.req.url) + return normalizeNextRoutesPath?.(url.href) || normalizePath?.(context) || url.pathname + } + + return async (context, next) => { + const url = new URL(context.req.url) + + if (bypass?.(context) || isBypassPath(url.pathname)) { + return next() + } + + const labels: PromClientLabelValues = {} + + const {timer: endTimer} = startTraceHistogram(DEFAULT_METRICS_TYPE.HTTP_REQUEST, labels) + + await next() + + labels['status_code'] = formatStatusCode?.(context) || getStatusCodeGroup(context.res.status) + labels.method = context.req.method + labels.path = extendedNormalizePath(context) + + endTimer?.() + } +} diff --git a/packages/hono/src/middleware/noop.ts b/packages/hono/src/middleware/noop.ts new file mode 100644 index 0000000..63f3fb5 --- /dev/null +++ b/packages/hono/src/middleware/noop.ts @@ -0,0 +1,11 @@ +import type {MiddlewareHandler} from 'hono' + +/** + * Creates a no-operation middleware that does nothing + * @returns Hono middleware that simply calls next() + */ +export function getHonoNoopMiddleware(): MiddlewareHandler { + return (_context, next) => { + return next() + } +} diff --git a/packages/hono/src/router/metrics.ts b/packages/hono/src/router/metrics.ts new file mode 100644 index 0000000..0cd717a --- /dev/null +++ b/packages/hono/src/router/metrics.ts @@ -0,0 +1,22 @@ +import {DEFAULT_METRICS_PATH, pm2Connector} from '@naverpay/prometheus-core' +import {Hono} from 'hono' + +import type {HonoPrometheusExporterOptions} from '../types' + +/** + * Creates a Hono app with metrics endpoint + * @param options - Configuration options including metrics path + * @returns Configured Hono app + */ +export function getHonoMetricsRouter({ + metricsPath = DEFAULT_METRICS_PATH, +}: Pick) { + const app = new Hono() + app.get(metricsPath, async (context) => { + const {metrics, metricsRegistry} = await pm2Connector.getMetrics() + return context.text(metrics, 200, { + 'Content-Type': metricsRegistry.contentType, + }) + }) + return app +} diff --git a/packages/hono/src/router/noop.ts b/packages/hono/src/router/noop.ts new file mode 100644 index 0000000..d5af1b8 --- /dev/null +++ b/packages/hono/src/router/noop.ts @@ -0,0 +1,22 @@ +import {HttpStatusCodes} from '@naverpay/es-http-status-codes' +import {DEFAULT_METRICS_PATH} from '@naverpay/prometheus-core' +import {Hono} from 'hono' + +import type {HonoPrometheusExporterOptions} from '../types' + +/** + * Creates a no-operation router that returns a simple message for metrics endpoint + * @param options - Configuration options including metrics path + * @returns Configured Hono app + */ +export function getNoopRouter({ + metricsPath = DEFAULT_METRICS_PATH, +}: Pick) { + const app = new Hono() + app.get(metricsPath, (context) => { + return context.text('[@naverpay/prometheus-hono] this router is a noop', HttpStatusCodes.OK, { + 'Content-Type': 'text/plain', + }) + }) + return app +} diff --git a/packages/hono/src/router/standalone.ts b/packages/hono/src/router/standalone.ts new file mode 100644 index 0000000..58cb1b5 --- /dev/null +++ b/packages/hono/src/router/standalone.ts @@ -0,0 +1,22 @@ +import {DEFAULT_METRICS_PATH, getStandaloneMetrics} from '@naverpay/prometheus-core' +import {Hono} from 'hono' + +import type {HonoPrometheusExporterOptions} from '../types' + +/** + * Creates a Hono app with metrics endpoint for standalone (non-PM2) mode + * @param options - Configuration options including metrics path + * @returns Configured Hono app + */ +export function getHonoStandaloneMetricsRouter({ + metricsPath = DEFAULT_METRICS_PATH, +}: Pick) { + const app = new Hono() + app.get(metricsPath, async (context) => { + const {metrics, contentType} = await getStandaloneMetrics() + return context.text(metrics, 200, { + 'Content-Type': contentType, + }) + }) + return app +} diff --git a/packages/hono/src/router/utils.ts b/packages/hono/src/router/utils.ts new file mode 100644 index 0000000..66a508e --- /dev/null +++ b/packages/hono/src/router/utils.ts @@ -0,0 +1,59 @@ +import type {Env, Hono, Schema} from 'hono' + +/** + * Extracts all route paths from a Hono app + * @param app - The Hono app instance + * @param prefix - Optional prefix to add to paths + * @returns Array of route paths + */ +export function getHonoRouterPaths(app: Hono, prefix = '') { + return app.routes.map(({path}) => `${prefix}${path}`) +} + +/** + * Creates regex testers for path matching + * @param pathnames - Array of path patterns + * @param prefix - Prefix to add to paths + * @returns Array of objects with regex and path + */ +export function createPathTesters(pathnames: string[], prefix: string) { + return pathnames.map((path) => { + const pathWithPrefix = `${prefix}${path}` + const pattern = `^${pathWithPrefix.replaceAll(/:\w+/g, '[^/]+')}/?$` + const regExp = new RegExp(pattern) + return {regExp, path: pathWithPrefix} + }) +} + +/** + * Gets normalized path by matching against regex testers + * @param pathname - The pathname to normalize + * @param testers - Array of regex testers + * @returns Normalized path or original pathname if no match + */ +export function getNormalizedPath(pathname: string, testers: {regExp: RegExp; path: string}[]) { + for (const {regExp, path} of testers) { + if (regExp.test(pathname)) { + return path + } + } + return pathname +} + +/** + * Creates a function that normalizes Hono router paths for metrics + * @param app - The Hono app instance + * @param prefix - Optional prefix for paths + * @returns Function that normalizes pathnames + */ +export function createNormalizedHonoRouterPath( + app: Hono, + prefix = '', +) { + const paths = getHonoRouterPaths(app, prefix) + const testers = createPathTesters(paths, prefix) + + return function getNormalizedHonoRouterPath(pathname: string) { + return getNormalizedPath(pathname, testers) + } +} diff --git a/packages/hono/src/types.ts b/packages/hono/src/types.ts new file mode 100644 index 0000000..ab3cd25 --- /dev/null +++ b/packages/hono/src/types.ts @@ -0,0 +1,12 @@ +import type {CommonPrometheusExporterOptions} from '@naverpay/prometheus-core' +import type {Context} from 'hono' + +/** Configuration options for Hono Prometheus exporter */ +export interface HonoPrometheusExporterOptions extends CommonPrometheusExporterOptions { + /** Function to determine if a request should be bypassed from metrics collection */ + bypass?: (context: Context) => boolean + /** Function to normalize/group request paths for metrics */ + normalizePath?: (context: Context) => string + /** Function to format status codes for metrics */ + formatStatusCode?: (context: Context) => string +} diff --git a/packages/hono/tsconfig.json b/packages/hono/tsconfig.json new file mode 100644 index 0000000..6eb94d1 --- /dev/null +++ b/packages/hono/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@naverpay/tsconfig/internal-package.json", + "include": ["./src", "./typings", "./__tests__"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/hono/vite.config.mts b/packages/hono/vite.config.mts new file mode 100644 index 0000000..9533dfe --- /dev/null +++ b/packages/hono/vite.config.mts @@ -0,0 +1,13 @@ +import {createViteConfig} from '@naverpay/pite' + +import type {UserConfig} from 'vite' + +const config: UserConfig = createViteConfig({ + cwd: '.', + entry: './src/index.ts', + options: { + minify: false, + }, +}) + +export default config diff --git a/packages/koa/README.md b/packages/koa/README.md index 10a68b2..770455b 100644 --- a/packages/koa/README.md +++ b/packages/koa/README.md @@ -36,7 +36,7 @@ npm install @naverpay/prometheus-koa ## 빠른 시작 -### 기본 설정 +### 기본 설정 (Standalone 모드) ```typescript import Koa from 'koa' @@ -44,9 +44,8 @@ import { createKoaPrometheusExporter } from '@naverpay/prometheus-koa' const app = new Koa() -// PM2 환경에서 실행하는 경우 +// 단일 프로세스 환경 (Docker, K8s 등) const { middleware, router, disconnect } = await createKoaPrometheusExporter({ - pm2: true, metricsPath: '/metrics', collectDefaultMetrics: true, }) @@ -69,14 +68,31 @@ process.on('SIGTERM', async () => { }) ``` -### PM2 없이 사용 +### PM2 클러스터 모드 + +```typescript +const { middleware, router, disconnect } = await createKoaPrometheusExporter({ + pm2: true, // PM2 클러스터 메트릭 집계 활성화 + metricsPath: '/metrics', +}) + +app.use(middleware) +app.use(router.routes()) + +process.on('SIGTERM', async () => { + await disconnect() +}) +``` + +### 메트릭 수집 비활성화 (개발 환경) ```typescript const { middleware, router } = await createKoaPrometheusExporter({ - pm2: false, + enabled: process.env.NODE_ENV === 'production', metricsPath: '/metrics', }) +// enabled: false일 경우 noop 미들웨어/라우터 반환 app.use(middleware) app.use(router.routes()) ``` @@ -91,13 +107,15 @@ Koa Prometheus 익스포터를 생성합니다. ```typescript interface KoaPrometheusExporterOptions { - /** PM2 클러스터링 지원 */ - pm2: boolean + /** 메트릭 수집 활성화 여부 (기본값: true) */ + enabled?: boolean + /** PM2 클러스터링 지원 (기본값: false) */ + pm2?: boolean /** Next.js 라우트 정규화 활성화 */ nextjs?: boolean /** 메트릭 엔드포인트 경로 */ metricsPath?: string - /** 기본 Node.js 메트릭 수집 */ + /** 기본 Node.js 메트릭 수집 (기본값: true) */ collectDefaultMetrics?: boolean /** 요청 바이패스 함수 */ bypass?: (context: Context) => boolean @@ -381,13 +399,21 @@ const { middleware } = await createKoaPrometheusExporter({ ### PM2 연결 실패 ```typescript -// PM2가 설치되지 않은 환경에서는 pm2: false로 설정 +// PM2 클러스터 환경에서만 pm2: true로 설정 const { middleware } = await createKoaPrometheusExporter({ - pm2: process.env.NODE_ENV === 'production', + pm2: process.env.PM2_HOME !== undefined, // 다른 설정들... }) ``` +### 개발 환경에서 메트릭 비활성화 + +```typescript +const { middleware } = await createKoaPrometheusExporter({ + enabled: process.env.NODE_ENV === 'production', +}) +``` + ### 메트릭 엔드포인트 접근 불가 ```typescript diff --git a/packages/koa/src/exporter.ts b/packages/koa/src/exporter.ts index ecef19f..3d47457 100644 --- a/packages/koa/src/exporter.ts +++ b/packages/koa/src/exporter.ts @@ -13,16 +13,22 @@ import {getKoaMetricsMiddleware} from './middleware/metrics' import {getKoaNoopMiddleware} from './middleware/noop' import {getKoaMetricsRouter} from './router/metrics' import {getNoopRouter} from './router/noop' +import {getKoaStandaloneMetricsRouter} from './router/standalone' import type {KoaPrometheusExporterOptions} from './types' +async function noop() { + // No operation +} + /** * Creates a Koa Prometheus exporter with middleware and router * @param options - Configuration options for the exporter * @returns Object containing router, middleware, and disconnect function */ export async function createKoaPrometheusExporter({ - pm2, + enabled = true, + pm2 = false, nextjs = true, metricsPath, collectDefaultMetrics = true, @@ -30,26 +36,26 @@ export async function createKoaPrometheusExporter({ normalizePath, formatStatusCode, }: KoaPrometheusExporterOptions) { - if (!pm2) { - const router = getNoopRouter({metricsPath}) - const middleware = getKoaNoopMiddleware() + // Disabled: return noop + if (!enabled) { return { - router, - middleware, - disconnect: async () => { - // noop - }, + router: getNoopRouter({metricsPath}), + middleware: getKoaNoopMiddleware(), + disconnect: noop, } } + // PM2 cluster mode: connect to PM2 if (pm2) { await pm2Connector.connect() } + // Register default metrics if (collectDefaultMetrics) { enableCollectDefaultMetrics() } + // Register HTTP request histogram registerHistogram( DEFAULT_METRICS_TYPE.HTTP_REQUEST, DEFAULT_METRICS_NAMES.http_request, @@ -60,11 +66,14 @@ export async function createKoaPrometheusExporter({ registerGaugeUp() const middleware = getKoaMetricsMiddleware({nextjs, bypass, normalizePath, formatStatusCode}) - const router = await getKoaMetricsRouter({metricsPath}) + + // PM2 mode: aggregated metrics from all workers + // Standalone mode: single process metrics + const router = pm2 ? getKoaMetricsRouter({metricsPath}) : getKoaStandaloneMetricsRouter({metricsPath}) return { router, middleware, - disconnect: pm2Connector.disconnect, + disconnect: pm2 ? pm2Connector.disconnect : noop, } } diff --git a/packages/koa/src/router/metrics.ts b/packages/koa/src/router/metrics.ts index 743e0cb..fcb103e 100644 --- a/packages/koa/src/router/metrics.ts +++ b/packages/koa/src/router/metrics.ts @@ -6,9 +6,9 @@ import type {KoaPrometheusExporterOptions} from '../types' /** * Creates a Koa router with metrics endpoint * @param options - Configuration options including metrics path - * @returns Promise resolving to configured Koa router + * @returns Configured Koa router */ -export async function getKoaMetricsRouter({ +export function getKoaMetricsRouter({ metricsPath = DEFAULT_METRICS_PATH, }: Pick) { const router = new Router() diff --git a/packages/koa/src/router/standalone.ts b/packages/koa/src/router/standalone.ts new file mode 100644 index 0000000..85e316a --- /dev/null +++ b/packages/koa/src/router/standalone.ts @@ -0,0 +1,21 @@ +import Router from '@koa/router' +import {DEFAULT_METRICS_PATH, getStandaloneMetrics} from '@naverpay/prometheus-core' + +import type {KoaPrometheusExporterOptions} from '../types' + +/** + * Creates a Koa router with metrics endpoint for standalone (non-PM2) mode + * @param options - Configuration options including metrics path + * @returns Configured Koa router + */ +export function getKoaStandaloneMetricsRouter({ + metricsPath = DEFAULT_METRICS_PATH, +}: Pick) { + const router = new Router() + router.get(metricsPath, async (context) => { + const {metrics, contentType} = await getStandaloneMetrics() + context.set('Content-Type', contentType) + context.body = metrics + }) + return router +} diff --git a/packages/next/README.md b/packages/next/README.md index ad890fc..afb5c36 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -152,7 +152,7 @@ interface NextjsPrometheusExporterOptions { } /** 메트릭 엔드포인트 경로 */ metricsPath?: string - /** 기본 Node.js 메트릭 수집 */ + /** 기본 Node.js 메트릭 수집 (기본값: true) */ collectDefaultMetrics?: boolean /** 요청 바이패스 함수 */ bypass?: (request: IncomingMessage, response: ServerResponse) => boolean diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aac8641..0e1f5db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,31 @@ importers: specifier: 'catalog:' version: 7.1.9(@types/node@22.18.8)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1) + packages/hono: + dependencies: + '@naverpay/es-http-status-codes': + specifier: ^0.0.4 + version: 0.0.4 + '@naverpay/prometheus-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@naverpay/pite': + specifier: 'catalog:' + version: 2.2.0(@babel/core@7.28.4)(rollup@4.52.4)(tsup@8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vite@7.1.9(@types/node@22.18.8)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1)) + '@naverpay/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + hono: + specifier: ^4.6.0 + version: 4.11.1 + vite: + specifier: 'catalog:' + version: 7.1.9(@types/node@22.18.8)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1) + packages/koa: dependencies: '@koa/router': @@ -2514,6 +2539,10 @@ packages: header-case@1.0.1: resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} + hono@4.11.1: + resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -7289,6 +7318,8 @@ snapshots: no-case: 2.3.2 upper-case: 1.1.3 + hono@4.11.1: {} + hosted-git-info@2.8.9: {} http-errors@2.0.0: