From 8891f3eba1f30e905f1f8e452aceb8985f752fe1 Mon Sep 17 00:00:00 2001 From: krutoo Date: Wed, 6 Mar 2024 13:53:27 +0500 Subject: [PATCH] #38 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preset: уменьшена вложенность каталога bun, добавлен каталог bun-handler (patch) - preset: healthCheck перенесен из isomorphic в server (major) - preset/server: fetchTracingMiddleware переименован в getFetchTracing (patch) - добавлены тесты --- .../{bun/handler => bun-handler}/index.ts | 16 +- .../providers/index.tsx | 16 +- src/preset/bun/bun/index.ts | 46 --- src/preset/bun/bun/utils/index.ts | 70 ----- src/preset/bun/index.ts | 50 ++- src/preset/bun/{bun => }/providers/index.ts | 119 ++----- src/preset/bun/{handler => }/utils/index.ts | 6 +- src/preset/isomorphic/constants.ts | 1 + .../isomorphic/utils/__test__/index.test.ts | 26 -- src/preset/isomorphic/utils/index.ts | 18 +- src/preset/node/handler/providers/index.tsx | 4 +- src/preset/server/index.ts | 2 +- .../server/utils/__test__/index.test.ts | 295 ++++++++++++++++++ src/preset/server/utils/index.ts | 179 ++++++++++- src/utils/__test__/function.test.ts | 12 + src/utils/__test__/math.test.ts | 7 + 16 files changed, 580 insertions(+), 287 deletions(-) rename src/preset/{bun/handler => bun-handler}/index.ts (76%) rename src/preset/{bun/handler => bun-handler}/providers/index.tsx (95%) delete mode 100644 src/preset/bun/bun/index.ts delete mode 100644 src/preset/bun/bun/utils/index.ts rename src/preset/bun/{bun => }/providers/index.ts (54%) rename src/preset/bun/{handler => }/utils/index.ts (80%) create mode 100644 src/preset/server/utils/__test__/index.test.ts create mode 100644 src/utils/__test__/function.test.ts create mode 100644 src/utils/__test__/math.test.ts diff --git a/src/preset/bun/handler/index.ts b/src/preset/bun-handler/index.ts similarity index 76% rename from src/preset/bun/handler/index.ts rename to src/preset/bun-handler/index.ts index 2c56c0e..95dc354 100644 --- a/src/preset/bun/handler/index.ts +++ b/src/preset/bun-handler/index.ts @@ -1,17 +1,17 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import { createPreset } from '../../../di'; -import { KnownToken } from '../../../tokens'; -import { PresetTuner } from '../../isomorphic'; +import { createPreset } from '../../di'; +import { KnownToken } from '../../tokens'; +import { PresetTuner } from '../isomorphic'; import { provideAbortController, provideFetch, provideReduxMiddlewareSaga, -} from '../../isomorphic/providers'; -import { providePageRender } from '../../node/handler/providers'; -import { SpecificExtras } from '../../node/handler/utils'; +} from '../isomorphic/providers'; +import { providePageRender } from '../node/handler/providers'; +import { SpecificExtras } from '../node/handler/utils'; import { HandlerProviders } from './providers'; -export function PresetHandler(customize?: PresetTuner) { +export function PresetBunHandler(customize?: PresetTuner) { const preset = createPreset(); // http fetch @@ -38,5 +38,3 @@ export function PresetHandler(customize?: PresetTuner) { return preset; } - -export { HandlerProvider } from './utils'; diff --git a/src/preset/bun/handler/providers/index.tsx b/src/preset/bun-handler/providers/index.tsx similarity index 95% rename from src/preset/bun/handler/providers/index.tsx rename to src/preset/bun-handler/providers/index.tsx index d021ba7..f029379 100644 --- a/src/preset/bun/handler/providers/index.tsx +++ b/src/preset/bun-handler/providers/index.tsx @@ -1,8 +1,8 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ import { renderToString } from 'react-dom/server'; -import { Resolve } from '../../../../di'; -import { KnownToken } from '../../../../tokens'; -import { HelmetContext, RegularHelmet } from '../../../node/handler/utils'; +import { Resolve } from '../../../di'; +import { KnownToken } from '../../../tokens'; +import { HelmetContext, RegularHelmet } from '../../node/handler/utils'; import { CookieStore, Middleware, @@ -14,12 +14,12 @@ import { log, LogHandler, LogHandlerFactory, -} from '../../../../http'; +} from '../../../http'; import { Fragment } from 'react'; -import { FetchLogging } from '../../../isomorphic/utils'; -import { getForwardedHeaders, getPageResponseFormat } from '../../../server/utils'; -import { PageAssets } from '../../../isomorphic/types'; -import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; +import { FetchLogging } from '../../isomorphic/utils'; +import { getForwardedHeaders, getPageResponseFormat } from '../../server/utils'; +import { PageAssets } from '../../isomorphic/types'; +import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; export const HandlerProviders = { handlerMain(resolve: Resolve) { diff --git a/src/preset/bun/bun/index.ts b/src/preset/bun/bun/index.ts deleted file mode 100644 index 5c6a732..0000000 --- a/src/preset/bun/bun/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import { createPreset } from '../../../di'; -import { KnownToken } from '../../../tokens'; -import { PresetTuner } from '../../isomorphic'; -import { provideBaseConfig } from '../../isomorphic/providers'; -import { provideKnownHttpApiHosts, provideSsrBridgeServerSide } from '../../node/node/providers'; -import { BunProviders } from './providers'; - -// @todo возможно стоит переименовать в PresetServer (так как в теории это можно использовать не только в Bun но и в Deno, Node.js) -export function PresetBun(customize?: PresetTuner) { - const preset = createPreset(); - - // config - preset.set(KnownToken.Config.source, BunProviders.configSource); - preset.set(KnownToken.Config.base, provideBaseConfig); - - // log - preset.set(KnownToken.logger, BunProviders.logger); - - // tracing - // @todo - - // metrics - preset.set(KnownToken.Metrics.httpHandler, BunProviders.serveMetrics); - - // http fetch - preset.set(KnownToken.Http.fetch, BunProviders.fetch); - preset.set(KnownToken.Http.Fetch.middleware, BunProviders.fetchMiddleware); - - // http serve - preset.set(KnownToken.Http.serve, BunProviders.serve); - preset.set(KnownToken.Http.Serve.serviceRoutes, BunProviders.serviceRoutes); - preset.set(KnownToken.Http.Serve.middleware, BunProviders.serveMiddleware); - - // http api - preset.set(KnownToken.Http.Api.knownHosts, provideKnownHttpApiHosts); - - // ssr bridge - preset.set(KnownToken.SsrBridge.serverSide, provideSsrBridgeServerSide); - - if (customize) { - customize({ override: preset.set.bind(preset) }); - } - - return preset; -} diff --git a/src/preset/bun/bun/utils/index.ts b/src/preset/bun/bun/utils/index.ts deleted file mode 100644 index 25242df..0000000 --- a/src/preset/bun/bun/utils/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import { DoneLogData, FailLogData, FetchUtil, LogData, LogHandler } from '../../../../http'; -import { DetailedError, Logger } from '../../../../log'; -import { toMilliseconds } from '../../../../utils'; -import { getClientIp } from '../../../server/utils'; - -/** - * Логирование обработки входящих http-запросов. - * @todo Перенести в preset/server? - */ -export class ServeLogging implements LogHandler { - logger: Logger; - timeMap: Map; - - constructor(logger: Logger) { - this.logger = logger; - this.timeMap = new Map(); - } - - onRequest({ request }: LogData) { - this.timeMap.set(request, process.hrtime.bigint()); - - this.logger.info({ - type: 'http.request[incoming]', - route: request.url, - method: request.method, - remote_ip: getClientIp(request), - }); - } - - onResponse({ response, request }: DoneLogData) { - const finish = process.hrtime.bigint(); - const start = this.timeMap.get(request) ?? finish; - - this.logger.info({ - type: 'http.response[outgoing]', - route: request.url, - method: request.method, - status: response.status, - remote_ip: getClientIp(request), - latency: toMilliseconds(finish - start), - }); - - // ВАЖНО: обязательно чистим - this.timeMap.delete(request); - } - - onCatch({ error, request }: FailLogData) { - this.logger.error( - new DetailedError(String(error), { - level: 'error', - context: [ - { - key: 'Incoming request details', - data: { - url: FetchUtil.withoutParams(request.url), - method: request.method, - headers: request.headers, - params: Object.fromEntries(new URL(request.url).searchParams.entries()), - // @todo data - }, - }, - ], - }), - ); - - // ВАЖНО: обязательно чистим - this.timeMap.delete(request); - } -} diff --git a/src/preset/bun/index.ts b/src/preset/bun/index.ts index f7cf168..66a28eb 100644 --- a/src/preset/bun/index.ts +++ b/src/preset/bun/index.ts @@ -1,2 +1,48 @@ -export { PresetBun } from './bun'; -export { PresetHandler, HandlerProvider } from './handler'; +/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ +import { createPreset } from '../../di'; +import { KnownToken } from '../../tokens'; +import { PresetTuner } from '../isomorphic'; +import { provideBaseConfig } from '../isomorphic/providers'; +import { provideKnownHttpApiHosts, provideSsrBridgeServerSide } from '../node/node/providers'; +import { BunProviders } from './providers'; + +// @todo возможно стоит переименовать в PresetServer (так как в теории это можно использовать не только в Bun но и в Deno, Node.js) +export function PresetBun(customize?: PresetTuner) { + const preset = createPreset(); + + // config + preset.set(KnownToken.Config.source, BunProviders.configSource); + preset.set(KnownToken.Config.base, provideBaseConfig); + + // log + preset.set(KnownToken.logger, BunProviders.logger); + + // tracing + // @todo + + // metrics + preset.set(KnownToken.Metrics.httpHandler, BunProviders.serveMetrics); + + // http fetch + preset.set(KnownToken.Http.fetch, BunProviders.fetch); + preset.set(KnownToken.Http.Fetch.middleware, BunProviders.fetchMiddleware); + + // http serve + preset.set(KnownToken.Http.serve, BunProviders.serve); + preset.set(KnownToken.Http.Serve.serviceRoutes, BunProviders.serviceRoutes); + preset.set(KnownToken.Http.Serve.middleware, BunProviders.serveMiddleware); + + // http api + preset.set(KnownToken.Http.Api.knownHosts, provideKnownHttpApiHosts); + + // ssr bridge + preset.set(KnownToken.SsrBridge.serverSide, provideSsrBridgeServerSide); + + if (customize) { + customize({ override: preset.set.bind(preset) }); + } + + return preset; +} + +export { HandlerProvider } from './utils'; diff --git a/src/preset/bun/bun/providers/index.ts b/src/preset/bun/providers/index.ts similarity index 54% rename from src/preset/bun/bun/providers/index.ts rename to src/preset/bun/providers/index.ts index a25452a..1136c37 100644 --- a/src/preset/bun/bun/providers/index.ts +++ b/src/preset/bun/providers/index.ts @@ -1,21 +1,23 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import { Resolve } from '../../../../di'; -import { KnownToken } from '../../../../tokens'; -import { ConfigSource, createConfigSource } from '../../../../config'; -import { Logger, createLogger } from '../../../../log'; -import { Handler, Middleware, log } from '../../../../http'; -import { ServeLogging } from '../utils'; -import { providePinoHandler } from '../../../node/node/providers'; +import { Resolve } from '../../../di'; +import { KnownToken } from '../../../tokens'; +import { ConfigSource, createConfigSource } from '../../../config'; +import { Logger, createLogger } from '../../../log'; +import { Handler, Middleware } from '../../../http'; +import { providePinoHandler } from '../../node/node/providers'; import { route, router } from '@krutoo/fetch-tools'; import { getCurrentHub, init, runWithAsyncContext } from '@sentry/bun'; -import { createSentryHandler } from '../../../../log/handler/sentry'; -import { healthCheck } from '../../../isomorphic/utils'; -import { provideFetch } from '../../../isomorphic/providers'; -import { toMilliseconds } from '../../../../utils'; -import { ServerHandler, ServerMiddleware } from '../../../server/types'; -import { applyServerMiddleware } from '../../../server/utils'; +import { createSentryHandler } from '../../../log/handler/sentry'; +import { provideFetch } from '../../isomorphic/providers'; +import { ServerHandler, ServerMiddleware } from '../../server/types'; +import { + applyServerMiddleware, + healthCheck, + getServeLogging, + getServeErrorLogging, + getServeMeasuring, +} from '../../server/utils'; import PromClient from 'prom-client'; -import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; export const BunProviders = { configSource(): ConfigSource { @@ -93,103 +95,20 @@ export const BunProviders = { const config = resolve(KnownToken.Config.base); const logger = resolve(KnownToken.logger); - const logging = new ServeLogging(logger); - - // @todo перенести в preset/server - const ConventionalLabels = { - HTTP_RESPONSE: ['version', 'route', 'code', 'method'], - SSR: ['version', 'route', 'method'], - } as const; - - const requestCount = new PromClient.Counter({ - name: 'http_request_count', - help: 'Incoming HTTP request count', - labelNames: ConventionalLabels.HTTP_RESPONSE, - }); - - const responseDuration = new PromClient.Histogram({ - name: 'http_response_duration_ms', - help: 'Duration of incoming HTTP requests in ms', - labelNames: ConventionalLabels.HTTP_RESPONSE, - buckets: [30, 100, 200, 500, 1000, 2500, 5000, 10000], - }); - - const renderDuration = new PromClient.Histogram({ - name: 'render_duration_ms', - help: 'Duration of SSR ms', - labelNames: ConventionalLabels.SSR, - buckets: [0.1, 15, 50, 100, 250, 500, 800, 1500], - }); - - const getLabels = ( - req: Request, - res: Response, - ): Record<(typeof ConventionalLabels.HTTP_RESPONSE)[number], string | number> => ({ - version: config.appVersion, - route: req.url, - code: res.status, - method: req.method, - }); - return [ // ВАЖНО: изолируем хлебные крошки чтобы они группировались по входящим запросам (request, next) => runWithAsyncContext(async () => next(request)), // ВАЖНО: слой логирования ошибки ПЕРЕД остальными слоями чтобы не упустить ошибки выше - log({ - onCatch: data => logging.onRequest(data), - }), + getServeErrorLogging(logger), // @todo tracing // метрики - async (request, next, context) => { - const responseStart = process.hrtime.bigint(); - let renderStart = 0n; - - context.events.addEventListener( - RESPONSE_EVENT_TYPE.renderStart, - () => { - renderStart = process.hrtime.bigint(); - }, - { once: true }, - ); - - context.events.addEventListener( - RESPONSE_EVENT_TYPE.renderFinish, - () => { - const renderFinish = process.hrtime.bigint(); - - renderDuration.observe( - { - version: config.appVersion, - method: request.method, - route: request.url, - }, - toMilliseconds(renderFinish - renderStart), - ); - }, - { once: true }, - ); - - const response = await next(request); - const responseFinish = process.hrtime.bigint(); - - responseDuration.observe( - getLabels(request, response), - toMilliseconds(responseFinish - responseStart), - ); - - requestCount.inc(getLabels(request, response), 1); - - return response; - }, + getServeMeasuring(config), // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные - log({ - onRequest: data => logging.onRequest(data), - onResponse: data => logging.onResponse(data), - }), + getServeLogging(logger), ]; }, diff --git a/src/preset/bun/handler/utils/index.ts b/src/preset/bun/utils/index.ts similarity index 80% rename from src/preset/bun/handler/utils/index.ts rename to src/preset/bun/utils/index.ts index 4dc18ff..0de426a 100644 --- a/src/preset/bun/handler/utils/index.ts +++ b/src/preset/bun/utils/index.ts @@ -1,7 +1,7 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import type { ServerHandler } from '../../../server/types'; -import { KnownToken } from '../../../../tokens'; -import { CURRENT_APP, type Application, type Resolve, Provider } from '../../../../di'; +import type { ServerHandler } from '../../server/types'; +import { KnownToken } from '../../../tokens'; +import { CURRENT_APP, type Application, type Resolve, Provider } from '../../../di'; export function HandlerProvider(getApp: () => Application): Provider { return (resolve: Resolve): ServerHandler => { diff --git a/src/preset/isomorphic/constants.ts b/src/preset/isomorphic/constants.ts index af27edf..c37c8e9 100644 --- a/src/preset/isomorphic/constants.ts +++ b/src/preset/isomorphic/constants.ts @@ -1,5 +1,6 @@ /** * События в процессе ответа на запрос. + * @todo Переименовать в PAGE_HANDLER_EVENT_TYPE и перенести в preset/server/constants. */ export const RESPONSE_EVENT_TYPE = { // ВАЖНО: пусть здесь остаются строки в качестве значений чтобы их можно было использовать вместе с EventTarget diff --git a/src/preset/isomorphic/utils/__test__/index.test.ts b/src/preset/isomorphic/utils/__test__/index.test.ts index 3b9da5a..b2997b0 100644 --- a/src/preset/isomorphic/utils/__test__/index.test.ts +++ b/src/preset/isomorphic/utils/__test__/index.test.ts @@ -13,7 +13,6 @@ import { severityFromStatus, HttpStatus, displayUrl, - healthCheck, FetchLogging, } from '..'; import { FetchUtil } from '../../../../http'; @@ -825,28 +824,3 @@ describe('FetchLogging', () => { }); }); }); - -describe('healthCheck', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('handler should return response', async () => { - const handler = healthCheck(); - const request = new Request(''); - - const res1 = await handler(request); - expect(res1.headers.get('content-type')).toBe('application/json'); - expect(await res1.json()).toEqual({ uptime: 0 }); - - jest.advanceTimersByTime(1000); - - const res2 = await handler(request); - expect(res2.headers.get('content-type')).toBe('application/json'); - expect(await res2.json()).toEqual({ uptime: 1000 }); - }); -}); diff --git a/src/preset/isomorphic/utils/index.ts b/src/preset/isomorphic/utils/index.ts index 4bfda9d..519492b 100644 --- a/src/preset/isomorphic/utils/index.ts +++ b/src/preset/isomorphic/utils/index.ts @@ -16,7 +16,7 @@ import { SagaInterruptInfo, SagaMiddlewareHandler, } from '../../../utils/redux-saga/types'; -import { DoneLogData, FailLogData, FetchUtil, Handler, LogData, LogHandler } from '../../../http'; +import { DoneLogData, FailLogData, FetchUtil, LogData, LogHandler } from '../../../http'; /** Реализация пула хостов. */ export class HttpApiHostPool implements StrictMap { @@ -482,19 +482,3 @@ export function displayUrl( return result; } - -/** - * Возвращает новый обработчик входящих запросов. - * Обработчик возвращает ответ в формате JSON, тело - объект с полем "uptime" типа number. - * @return Обработчик. - */ -export function healthCheck(): Handler { - const startTime = Date.now(); - - return () => - new Response(JSON.stringify({ uptime: Date.now() - startTime }), { - headers: { - 'content-type': 'application/json', - }, - }); -} diff --git a/src/preset/node/handler/providers/index.tsx b/src/preset/node/handler/providers/index.tsx index 049278b..a308c95 100644 --- a/src/preset/node/handler/providers/index.tsx +++ b/src/preset/node/handler/providers/index.tsx @@ -22,7 +22,7 @@ import type { ConventionalJson } from '../../../isomorphic/types'; import { Fragment } from 'react'; import { HelmetContext, RegularHelmet, getPageResponseFormat } from '../utils'; import { renderToString } from 'react-dom/server'; -import { fetchTracingMiddleware } from '../../../server/utils'; +import { getFetchTracing } from '../../../server/utils'; /** * Провайдер главной функции обработчика входящего http-запроса. @@ -209,7 +209,7 @@ export function provideFetchMiddleware(resolve: Resolve): Middleware[] { cookie(cookieStore), - fetchTracingMiddleware(tracer, context.res.locals.tracing.rootContext), + getFetchTracing(tracer, context.res.locals.tracing.rootContext), // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные log(initData => { diff --git a/src/preset/server/index.ts b/src/preset/server/index.ts index 85b6ee5..0b1b664 100644 --- a/src/preset/server/index.ts +++ b/src/preset/server/index.ts @@ -1,2 +1,2 @@ export type { ServerHandler, ServerMiddleware, ServerHandlerContext } from './types'; -export { getClientIp, getForwardedHeaders, getPageResponseFormat } from './utils'; +export { getClientIp, getForwardedHeaders, getPageResponseFormat, healthCheck } from './utils'; diff --git a/src/preset/server/utils/__test__/index.test.ts b/src/preset/server/utils/__test__/index.test.ts new file mode 100644 index 0000000..9bb59d0 --- /dev/null +++ b/src/preset/server/utils/__test__/index.test.ts @@ -0,0 +1,295 @@ +import { + healthCheck, + getPageResponseFormat, + getForwardedHeaders, + getServeLogging, + getServeErrorLogging, + getServeMeasuring, + getFetchTracing, + applyServerMiddleware, +} from '..'; +import { BaseConfig } from '../../../../config'; +import { DetailedError, createLogger } from '../../../../log'; +import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; +import { ServerMiddleware } from '../../types'; + +describe('getPageResponseFormat', () => { + it('should return json', () => { + const request = new Request('http://test.com', { + headers: { accept: 'application/json' }, + }); + + expect(getPageResponseFormat(request)).toBe('json'); + }); + + it('should return html when no accept', () => { + const request = new Request('http://test.com', { + headers: { accept: '' }, + }); + + expect(getPageResponseFormat(request)).toBe('html'); + }); + + it('should return html', () => { + const request = new Request('http://test.com', { + headers: { accept: 'text/html' }, + }); + + expect(getPageResponseFormat(request)).toBe('html'); + }); +}); + +describe('getForwardedHeaders', () => { + it('should contain headers by convention', () => { + const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; + const request = new Request('http://test.com', { + headers: { + 'x-client-ip': '127.0.0.89', + 'simaland-foo': 'hello', + 'simaland-bar': 'world', + 'simaland-params': JSON.stringify({ testParam: 123 }), + }, + }); + const result = getForwardedHeaders(config, request); + + expect(result.get('user-agent')).toBe('simaland-app_name/version'); + expect(result.get('x-client-ip')).toBe('127.0.0.89'); + expect(result.get('simaland-foo')).toBe('hello'); + expect(result.get('simaland-bar')).toBe('world'); + expect(result.get('simaland-params')).toBe(null); + }); + + it('should not contain x-client-ip when client ip is not defined', () => { + const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; + const request = new Request('http://test.com', { + headers: { + 'simaland-foo': 'hello', + 'simaland-bar': 'world', + }, + }); + const result = getForwardedHeaders(config, request); + + expect(result.get('user-agent')).toBe('simaland-app_name/version'); + expect(result.get('x-client-ip')).toBe(null); + expect(result.get('simaland-foo')).toBe('hello'); + expect(result.get('simaland-bar')).toBe('world'); + }); +}); + +describe('healthCheck', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('handler should return response', async () => { + const handler = healthCheck(); + const request = new Request(''); + + const res1 = await handler(request); + expect(res1.headers.get('content-type')).toBe('application/json'); + expect(await res1.json()).toEqual({ uptime: 0 }); + + jest.advanceTimersByTime(1000); + + const res2 = await handler(request); + expect(res2.headers.get('content-type')).toBe('application/json'); + expect(await res2.json()).toEqual({ uptime: 1000 }); + }); +}); + +describe('getServeLogging', () => { + it('should log request and response', async () => { + const spy = jest.fn(); + const logger = createLogger(); + + logger.subscribe(spy); + + const middleware = getServeLogging(logger); + + const signal = new EventTarget(); + + const next = () => + new Promise(resolve => { + signal.addEventListener( + 'response', + () => { + resolve(new Response('OK', { status: 200 })); + }, + { once: true }, + ); + }); + + middleware(new Request('http://test.ru'), next); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'info', + data: { + type: 'http.request[incoming]', + route: 'http://test.ru', + method: 'GET', + remote_ip: null, + }, + }); + + signal.dispatchEvent(new Event('response')); + await Promise.resolve(); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0]).toEqual({ + type: 'info', + data: { + type: 'http.response[outgoing]', + route: 'http://test.ru', + method: 'GET', + status: 200, + remote_ip: null, + latency: expect.any(Number), + }, + }); + }); +}); + +describe('getServeErrorLogging', () => { + it('should log error', async () => { + const spy = jest.fn(); + const logger = createLogger(); + + logger.subscribe(spy); + + const request = new Request('http://test.ru'); + const middleware = getServeErrorLogging(logger); + + await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(() => {}); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'error', + data: new DetailedError('FAKE ERROR', { + level: 'error', + context: [ + { + key: 'Incoming request details', + data: { + url: 'http://test.ru', + method: 'GET', + headers: {}, + params: {}, + }, + }, + ], + }), + }); + }); +}); + +describe('getServeMeasuring', () => { + it('should collect metrics', async () => { + const config: BaseConfig = { env: 'test', appName: 'testApp', appVersion: 'testVer' }; + const middleware = getServeMeasuring(config); + const events = new EventTarget(); + + expect(async () => { + await middleware( + new Request('http://test.com'), + () => { + events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderStart)); + events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderFinish)); + return Promise.resolve(new Response('OK')); + }, + { + events, + }, + ); + }).not.toThrow(); + }); +}); + +describe('getFetchTracing', () => { + it('should trace fetch stages', async () => { + const tracer: any = { + startSpan: jest.fn(() => ({ + setAttributes: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + })), + }; + const context: any = {}; + const middleware = getFetchTracing(tracer, context); + + const request = new Request('http://test.com/api/v2/product/1005002'); + + expect( + await Promise.resolve( + middleware(request, () => Promise.resolve(new Response('OK'))), + ).catch(() => 'catch!'), + ).not.toBe('catch!'); + }); + + it('should handle error', async () => { + const tracer: any = { + startSpan: jest.fn(() => ({ + setAttributes: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + })), + }; + const context: any = {}; + const middleware = getFetchTracing(tracer, context); + + const request = new Request('http://test.com/api/v2/product/1005002'); + + expect( + await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(e => e), + ).toBe('FAKE ERROR'); + }); +}); + +describe('applyServerMiddleware', () => { + it('should compose middleware', async () => { + const log: string[] = []; + + const foo: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const bar: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const baz: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const enhancer = applyServerMiddleware(foo, bar, baz); + + const handler = enhancer(() => { + log.push(''); + return new Response('Test'); + }); + + await handler(new Request('http://test.com'), { events: new EventTarget() }); + + expect(log).toEqual([ + // expected log + '', + '', + '', + '', + '', + '', + '', + ]); + }); +}); diff --git a/src/preset/server/utils/index.ts b/src/preset/server/utils/index.ts index 97e58ce..38fd48b 100644 --- a/src/preset/server/utils/index.ts +++ b/src/preset/server/utils/index.ts @@ -1,9 +1,13 @@ import type { BaseConfig } from '../../../config'; -import type { Middleware } from '../../../http'; +import { Handler, Middleware, FetchUtil, log } from '../../../http'; import type { ServerEnhancer, ServerMiddleware } from '../types'; import { SpanStatusCode, type Context, type Tracer } from '@opentelemetry/api'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { hideFirstId } from '../../node/node/utils/http-client'; +import { DetailedError, Logger } from '../../../log'; +import { toMilliseconds } from '../../../utils'; +import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; +import PromClient from 'prom-client'; /** * Определяет формат ответа для страницы (html-верстки). @@ -65,13 +69,179 @@ export function getClientIp(request: Request): string | null { return headerValue; } +/** + * Возвращает новый обработчик входящих запросов. + * Обработчик возвращает ответ в формате JSON, тело - объект с полем "uptime" типа number. + * @return Обработчик. + */ +export function healthCheck(): Handler { + const startTime = Date.now(); + + return () => + new Response(JSON.stringify({ uptime: Date.now() - startTime }), { + headers: { + 'content-type': 'application/json', + }, + }); +} + +/** + * Возвращает новый промежуточный слой для логирования обработки входящего http-запроса. + * @param logger Logger. + * @return Промежуточный слой. + */ +export function getServeLogging(logger: Logger): Middleware { + return async (request, next) => { + const start = process.hrtime.bigint(); + + logger.info({ + type: 'http.request[incoming]', + route: request.url, + method: request.method, + remote_ip: getClientIp(request), + }); + + const response = await next(request); + + const finish = process.hrtime.bigint(); + + logger.info({ + type: 'http.response[outgoing]', + route: request.url, + method: request.method, + status: response.status, + remote_ip: getClientIp(request), + latency: toMilliseconds(finish - start), + }); + + return response; + }; +} + +/** + * Возвращает новый промежуточный слой для логирования ошибки при обработке входящего http-запроса. + * @param logger Logger. + * @return Промежуточный слой. + */ +export function getServeErrorLogging(logger: Logger) { + return log({ + onCatch: ({ request, error }) => { + logger.error( + new DetailedError(String(error), { + level: 'error', + context: [ + { + key: 'Incoming request details', + data: { + url: FetchUtil.withoutParams(request.url), + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + params: Object.fromEntries(new URL(request.url).searchParams.entries()), + // @todo data + }, + }, + ], + }), + ); + }, + }); +} + +/** + * Возвращает новый промежуточный слой метрки входящего http-запроса. + * @param config Конфигурация. + * @return Промежуточный слой. + */ +export function getServeMeasuring(config: BaseConfig): ServerMiddleware { + const ConventionalLabels = { + HTTP_RESPONSE: ['version', 'route', 'code', 'method'], + SSR: ['version', 'route', 'method'], + } as const; + + // @todo скорее всего стоит сделать обертки над классами из PromClient чтобы вызывать у них методы замера а они уже сами вычисляли лейблы и тд + const requestCount = new PromClient.Counter({ + name: 'http_request_count', + help: 'Incoming HTTP request count', + labelNames: ConventionalLabels.HTTP_RESPONSE, + }); + + const responseDuration = new PromClient.Histogram({ + name: 'http_response_duration_ms', + help: 'Duration of incoming HTTP requests in ms', + labelNames: ConventionalLabels.HTTP_RESPONSE, + buckets: [30, 100, 200, 500, 1000, 2500, 5000, 10000], + }); + + const renderDuration = new PromClient.Histogram({ + name: 'render_duration_ms', + help: 'Duration of SSR ms', + labelNames: ConventionalLabels.SSR, + buckets: [0.1, 15, 50, 100, 250, 500, 800, 1500], + }); + + /** @inheritdoc */ + const getResponseLabels = (req: Request, res: Response) => + ({ + version: config.appVersion, + route: req.url, + code: res.status, + method: req.method, + }) satisfies Record<(typeof ConventionalLabels.HTTP_RESPONSE)[number], string | number>; + + /** @inheritdoc */ + const getRenderLabels = (request: Request) => + ({ + version: config.appVersion, + method: request.method, + route: request.url, + }) satisfies Record<(typeof ConventionalLabels.SSR)[number], string | number>; + + return async (request, next, context) => { + const responseStart = process.hrtime.bigint(); + let renderStart = 0n; + + context.events.addEventListener( + RESPONSE_EVENT_TYPE.renderStart, + () => { + renderStart = process.hrtime.bigint(); + }, + { once: true }, + ); + + context.events.addEventListener( + RESPONSE_EVENT_TYPE.renderFinish, + () => { + const renderFinish = process.hrtime.bigint(); + + renderDuration.observe( + getRenderLabels(request), + toMilliseconds(renderFinish - renderStart), + ); + }, + { once: true }, + ); + + const response = await next(request); + const responseFinish = process.hrtime.bigint(); + + responseDuration.observe( + getResponseLabels(request, response), + toMilliseconds(responseFinish - responseStart), + ); + + requestCount.inc(getResponseLabels(request, response), 1); + + return response; + }; +} + /** * Вернет новый промежуточный слой трассировки для fetch. * @param tracer Трассировщик. * @param rootContext Контекст. * @return Промежуточный слой трассировки. */ -export function fetchTracingMiddleware(tracer: Tracer, rootContext: Context): Middleware { +export function getFetchTracing(tracer: Tracer, rootContext: Context): Middleware { return async (request, next) => { const [url, foundId] = hideFirstId(new URL(request.url).pathname); const span = tracer.startSpan(`HTTP ${request.method} ${url}`, undefined, rootContext); @@ -106,7 +276,10 @@ export function fetchTracingMiddleware(tracer: Tracer, rootContext: Context): Mi }; } -/** @inheritdoc */ +/** + * @inheritdoc + * @internal + */ export function applyServerMiddleware(...list: ServerMiddleware[]): ServerEnhancer { return handler => { let result = handler; diff --git a/src/utils/__test__/function.test.ts b/src/utils/__test__/function.test.ts new file mode 100644 index 0000000..957c952 --- /dev/null +++ b/src/utils/__test__/function.test.ts @@ -0,0 +1,12 @@ +import { safetyAsync } from '../function'; + +describe('safetyAsync', () => { + it('should return safe async function', async () => { + const fn = safetyAsync((n: number) => + n % 2 === 0 ? Promise.resolve('even') : Promise.reject('odd'), + ); + + expect(await fn(2)).toEqual({ ok: true, result: 'even' }); + expect(await fn(3)).toEqual({ ok: false, error: 'odd' }); + }); +}); diff --git a/src/utils/__test__/math.test.ts b/src/utils/__test__/math.test.ts new file mode 100644 index 0000000..8c54c4d --- /dev/null +++ b/src/utils/__test__/math.test.ts @@ -0,0 +1,7 @@ +import { toMilliseconds } from '../math'; + +describe('toMilliseconds', () => { + it('should works properly', () => { + expect(toMilliseconds(300000000n)).toBe(300); + }); +});