diff --git a/src/preset/bun/bun/index.ts b/src/preset/bun/bun/index.ts index fecb5d7..dc67cd4 100644 --- a/src/preset/bun/bun/index.ts +++ b/src/preset/bun/bun/index.ts @@ -21,7 +21,7 @@ export function PresetBun(customize?: PresetTuner) { // @todo // metrics - // @todo + preset.set(KnownToken.Metrics.httpHandler, BunProviders.serveMetrics); // http fetch preset.set(KnownToken.Http.fetch, BunProviders.fetch); diff --git a/src/preset/bun/bun/providers/index.ts b/src/preset/bun/bun/providers/index.ts index 528ff35..78ce57c 100644 --- a/src/preset/bun/bun/providers/index.ts +++ b/src/preset/bun/bun/providers/index.ts @@ -3,7 +3,7 @@ import { Resolve } from '../../../../di'; import { KnownToken } from '../../../../tokens'; import { ConfigSource, createConfigSource } from '../../../../config'; import { Logger, createLogger } from '../../../../log'; -import { Handler, Middleware, applyMiddleware, log } from '../../../../http'; +import { Handler, Middleware, log } from '../../../../http'; import { ServeLogging } from '../utils'; import { providePinoHandler } from '../../../node/node/providers'; import { route, router } from '@krutoo/fetch-tools'; @@ -11,6 +11,11 @@ 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 { ServerMiddleware } from '../../../server/types'; +import { applyServerMiddleware } from '../../../server/utils'; +import PromClient from 'prom-client'; +import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; export const BunProviders = { configSource(): ConfigSource { @@ -54,22 +59,65 @@ export const BunProviders = { const middleware = resolve(KnownToken.Http.Serve.middleware); const routes = resolve(KnownToken.Http.Serve.routes); - const enhance = applyMiddleware(...middleware); + const enhance = applyServerMiddleware(...middleware); return router( // маршруты с промежуточными слоями - ...routes.map(([pathname, handler]) => route(pathname, enhance(handler))), + ...routes.map(([pathname, handler]) => { + const enhancedHandler = enhance(handler); + return route(pathname, request => enhancedHandler(request, { events: new EventTarget() })); + }), + + // @todo вместо routes обрабатывать pageRoutes с помощью route.get() из новой версии fetch-tools (для явности) + // @todo также добавить apiRoutes и обрабатывать их с помощью с помощью route()? // служебные маршруты (без промежуточных слоев) route('/healthcheck', healthCheck()), ); }, - serveMiddleware(resolve: Resolve): Middleware[] { + serveMiddleware(resolve: Resolve): ServerMiddleware[] { + 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)), @@ -79,7 +127,50 @@ export const BunProviders = { onCatch: data => logging.onRequest(data), }), - // @todo metrics, tracing + // @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; + }, // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные log({ @@ -88,4 +179,19 @@ export const BunProviders = { }), ]; }, + + serveMetrics(): Handler { + // @todo задействовать когда Bun реализует pref_hooks.monitorEventLoopDelay (https://github.com/siimon/prom-client/issues/570) + // PromClient.collectDefaultMetrics(); + + // @todo здесь или в другом компоненте надо проверять путь и метод запроса + return async () => { + const metrics = await PromClient.register.metrics(); + const headers = new Headers(); + + headers.set('Content-Type', PromClient.register.contentType); + + return new Response(metrics, { headers }); + }; + }, } as const; diff --git a/src/preset/bun/handler/providers/index.tsx b/src/preset/bun/handler/providers/index.tsx index 90b00b3..1b07d00 100644 --- a/src/preset/bun/handler/providers/index.tsx +++ b/src/preset/bun/handler/providers/index.tsx @@ -18,6 +18,8 @@ import { 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'; export const HandlerProviders = { handlerMain(resolve: Resolve) { @@ -28,17 +30,68 @@ export const HandlerProviders = { const extras = resolve(KnownToken.Http.Handler.Response.specificExtras); const Helmet = resolve(KnownToken.Http.Handler.Page.helmet); const abortController = resolve(KnownToken.Http.Fetch.abortController); + const context = resolve(KnownToken.Http.Handler.context); const getAssets = typeof assetsInit === 'function' ? assetsInit : () => assetsInit; const elementToString = (element: JSX.Element) => { - // @todo dispatch renderStart event for metrics/tracing + context.events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderStart)); const result = renderToString(element); - // @todo dispatch renderFinish event for metrics/tracing + context.events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderFinish)); return result; }; + const getResponseHTML = (jsx: React.JSX.Element, assets: PageAssets, meta: unknown) => { + const headers = new Headers(); + + headers.set('content-type', 'text/html'); + headers.set('simaland-bundle-js', assets.js); + headers.set('simaland-bundle-css', assets.css); + + if (assets.criticalJs) { + headers.set('simaland-critical-js', assets.criticalJs); + } + + if (assets.criticalCss) { + headers.set('simaland-critical-css', assets.criticalCss); + } + + if (meta) { + headers.set('simaland-meta', JSON.stringify(meta)); + } + + // ВАЖНО: DOCTYPE обязательно нужен так как влияет на то как браузер будет парсить html/css + // ВАЖНО: DOCTYPE нужен только когда отдаем полноценную страницу + if (config.env === 'development') { + return new Response(`${elementToString(jsx)}`, { + headers, + }); + } else { + return new Response(elementToString(jsx), { + headers, + }); + } + }; + + const getResponseJSON = (jsx: React.JSX.Element, assets: PageAssets, meta: unknown) => { + const headers = new Headers(); + + headers.set('content-type', 'application/json'); + + return new Response( + JSON.stringify({ + markup: elementToString(jsx), + bundle_js: assets.js, + bundle_css: assets.css, + critical_js: assets.criticalJs, + critical_css: assets.criticalCss, + meta, + }), + { headers }, + ); + }; + const handler = async (request: Request): Promise => { try { const assets = await getAssets(); @@ -52,53 +105,10 @@ export const HandlerProviders = { switch (getPageResponseFormat(request)) { case 'html': { - const headers = new Headers(); - - headers.set('content-type', 'text/html'); - headers.set('simaland-bundle-js', assets.js); - headers.set('simaland-bundle-css', assets.css); - - if (assets.criticalJs) { - headers.set('simaland-critical-js', assets.criticalJs); - } - - if (assets.criticalCss) { - headers.set('simaland-critical-css', assets.criticalCss); - } - - if (meta) { - headers.set('simaland-meta', JSON.stringify(meta)); - } - - // ВАЖНО: DOCTYPE обязательно нужен так как влияет на то как браузер будет парсить html/css - // ВАЖНО: DOCTYPE нужен только когда отдаем полноценную страницу - if (config.env === 'development') { - return new Response(`${elementToString(jsx)}`, { - headers, - }); - } else { - return new Response(elementToString(jsx), { - headers, - }); - } + return getResponseHTML(jsx, assets, meta); } - case 'json': { - const headers = new Headers(); - - headers.set('content-type', 'application/json'); - - return new Response( - JSON.stringify({ - markup: elementToString(jsx), - bundle_js: assets.js, - bundle_css: assets.css, - critical_js: assets.criticalJs, - critical_css: assets.criticalCss, - meta, - }), - { headers }, - ); + return getResponseJSON(jsx, assets, meta); } } } catch (error) { diff --git a/src/preset/bun/handler/utils/index.ts b/src/preset/bun/handler/utils/index.ts index 7ebb871..45c3348 100644 --- a/src/preset/bun/handler/utils/index.ts +++ b/src/preset/bun/handler/utils/index.ts @@ -1,19 +1,19 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ -import type { Handler } from '../../../../http'; +import type { ServerHandler } from '../../../server/types'; import { KnownToken } from '../../../../tokens'; import { CURRENT_APP, type Application, type Resolve } from '../../../../di'; export function HandlerProvider(getApp: () => Application) { - return (resolve: Resolve): Handler => { + return (resolve: Resolve): ServerHandler => { const parent = resolve(CURRENT_APP); - return request => { + return (request, context) => { const app = getApp(); app.attach(parent); - app.bind(KnownToken.Http.Handler.context).toValue({ request }); + app.bind(KnownToken.Http.Handler.context).toValue({ request, ...context }); - return app.get(KnownToken.Http.Handler.main)(request); + return app.get(KnownToken.Http.Handler.main)(request, context); }; }; } diff --git a/src/preset/node/handler/providers/index.tsx b/src/preset/node/handler/providers/index.tsx index 65f88b5..0767c1c 100644 --- a/src/preset/node/handler/providers/index.tsx +++ b/src/preset/node/handler/providers/index.tsx @@ -12,7 +12,7 @@ import type { Resolve } from '../../../../di'; import { KnownToken } from '../../../../tokens'; import { getForwardedHeaders as getForwardedHeadersExpress, - tracingMiddleware as tracingMiddlewareAxios, + axiosTracingMiddleware, } from '../../node/utils/http-client'; import type { Middleware as AxiosMiddleware } from 'middleware-axios'; import { AxiosLogging, FetchLogging, HttpStatus } from '../../../isomorphic/utils'; @@ -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 { tracingMiddleware } from '../../../server/utils'; +import { fetchTracingMiddleware } from '../../../server/utils'; /** * Провайдер главной функции обработчика входящего http-запроса. @@ -201,7 +201,7 @@ export function provideFetchMiddleware(resolve: Resolve): Middleware[] { cookie(cookieStore), - tracingMiddleware(tracer, context.res.locals.tracing.rootContext), + fetchTracingMiddleware(tracer, context.res.locals.tracing.rootContext), // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные log(initData => { @@ -280,7 +280,7 @@ export function provideAxiosMiddleware(resolve: Resolve): AxiosMiddleware[] }, HttpStatus.axiosMiddleware(), - tracingMiddlewareAxios(tracer, context.res.locals.tracing.rootContext), + axiosTracingMiddleware(tracer, context.res.locals.tracing.rootContext), logMiddleware(logHandler), cookieMiddleware(cookieStore), ]; diff --git a/src/preset/node/index.ts b/src/preset/node/index.ts index 9c5db65..1fd5b24 100644 --- a/src/preset/node/index.ts +++ b/src/preset/node/index.ts @@ -1,3 +1,3 @@ -export type { HandlerContext } from './types'; +export type { ExpressHandlerContext } from './types'; export { PresetNode } from './node'; export { PresetHandler, HandlerProvider } from './handler'; diff --git a/src/preset/node/node/utils/http-client/__test__/index.test.ts b/src/preset/node/node/utils/http-client/__test__/index.test.ts index 9ed9081..c63775e 100644 --- a/src/preset/node/node/utils/http-client/__test__/index.test.ts +++ b/src/preset/node/node/utils/http-client/__test__/index.test.ts @@ -1,11 +1,11 @@ import { Context } from '@opentelemetry/api'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { Span, Tracer } from '@opentelemetry/sdk-trace-base'; -import { tracingMiddleware, getRequestInfo, hideFirstId, getForwardedHeaders } from '..'; +import { axiosTracingMiddleware, getRequestInfo, hideFirstId, getForwardedHeaders } from '..'; import { BaseConfig } from '../../../../../../config'; import { Request } from 'express'; -describe('tracingMiddleware', () => { +describe('axiosTracingMiddleware', () => { it('should handle success response', async () => { let currentSpan: Span = null as any; @@ -25,7 +25,7 @@ describe('tracingMiddleware', () => { const context: Context = {} as any; - const middleware = tracingMiddleware(tracer, context); + const middleware = axiosTracingMiddleware(tracer, context); expect(currentSpan).toBe(null); @@ -58,7 +58,7 @@ describe('tracingMiddleware', () => { const context: Context = {} as any; - const middleware = tracingMiddleware(tracer, context); + const middleware = axiosTracingMiddleware(tracer, context); expect(currentSpan).toBe(null); diff --git a/src/preset/node/node/utils/http-client/index.ts b/src/preset/node/node/utils/http-client/index.ts index df0e48c..0490b91 100644 --- a/src/preset/node/node/utils/http-client/index.ts +++ b/src/preset/node/node/utils/http-client/index.ts @@ -13,7 +13,7 @@ import { displayUrl } from '../../../../isomorphic/utils'; * @param rootContext Контекст. * @return Middleware. */ -export function tracingMiddleware(tracer: Tracer, rootContext: Context): Middleware { +export function axiosTracingMiddleware(tracer: Tracer, rootContext: Context): Middleware { return async function trace(config, next, defaults) { const { method, url, foundId } = getRequestInfo(config, defaults); const span = tracer.startSpan(`HTTP ${method} ${url}`, undefined, rootContext); @@ -114,7 +114,7 @@ export function getForwardedHeaders( result['X-Client-Ip'] = clientIp; } - const cookie = request.get('cookie'); + const cookie = request.header('cookie'); if (cookie) { result.Cookie = cookie; } diff --git a/src/preset/node/node/utils/http-server/index.ts b/src/preset/node/node/utils/http-server/index.ts index 422d3e6..5fdbfa3 100644 --- a/src/preset/node/node/utils/http-server/index.ts +++ b/src/preset/node/node/utils/http-server/index.ts @@ -8,8 +8,8 @@ import net from 'node:net'; */ export function getClientIp(request: Request): string | undefined { const headerValue = - request.get('x-client-ip') || - request.get('x-forwarded-for') || + request.header('x-client-ip') || + request.header('x-forwarded-for') || request.socket.remoteAddress || ''; diff --git a/src/preset/node/node/utils/index.ts b/src/preset/node/node/utils/index.ts index 7fc4f00..15efd49 100644 --- a/src/preset/node/node/utils/index.ts +++ b/src/preset/node/node/utils/index.ts @@ -1,2 +1,2 @@ -export { tracingMiddleware } from './http-client'; +export { axiosTracingMiddleware } from './http-client'; export { getClientIp } from './http-server'; diff --git a/src/preset/node/types.ts b/src/preset/node/types.ts index a842a28..5916206 100644 --- a/src/preset/node/types.ts +++ b/src/preset/node/types.ts @@ -3,7 +3,7 @@ import type { Request, Response, NextFunction } from 'express'; /** * Контекст обработчика express. */ -export interface HandlerContext { +export interface ExpressHandlerContext { req: Request; res: Response; next: NextFunction; diff --git a/src/preset/server/types.ts b/src/preset/server/types.ts new file mode 100644 index 0000000..821370b --- /dev/null +++ b/src/preset/server/types.ts @@ -0,0 +1,23 @@ +/** + * На сервере между промежуточными слоями надо обмениваться данными поэтому появился такой интерфейс. + * Возможно в будущем он перейдет в `@krutoo/fetch-tools`. + */ +export interface ServerHandlerContext { + events: EventTarget; +} + +export interface ServerHandler { + (request: Request, context: ServerHandlerContext): Response | Promise; +} + +export interface ServerEnhancer { + (request: ServerHandler): ServerHandler; +} + +export interface ServerMiddleware { + ( + request: Request, + next: (req: Request, ctx?: ServerHandlerContext) => Response | Promise, + context: ServerHandlerContext, + ): Response | Promise; +} diff --git a/src/preset/server/utils/index.ts b/src/preset/server/utils/index.ts index 689c1a8..97e58ce 100644 --- a/src/preset/server/utils/index.ts +++ b/src/preset/server/utils/index.ts @@ -1,5 +1,6 @@ import type { BaseConfig } from '../../../config'; import type { Middleware } 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'; @@ -70,7 +71,7 @@ export function getClientIp(request: Request): string | null { * @param rootContext Контекст. * @return Промежуточный слой трассировки. */ -export function tracingMiddleware(tracer: Tracer, rootContext: Context): Middleware { +export function fetchTracingMiddleware(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); @@ -104,3 +105,18 @@ export function tracingMiddleware(tracer: Tracer, rootContext: Context): Middlew } }; } + +/** @inheritdoc */ +export function applyServerMiddleware(...list: ServerMiddleware[]): ServerEnhancer { + return handler => { + let result = handler; + + for (const item of list.reverse()) { + const next = result; + result = async (request, context) => + item(request, (req, ctx) => next(req, ctx ?? context), context); + } + + return result; + }; +} diff --git a/src/tokens.ts b/src/tokens.ts index 6e96013..45e3731 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -13,12 +13,13 @@ import type { BasicTracerProvider, SpanExporter } from '@opentelemetry/sdk-trace import type { Resource } from '@opentelemetry/resources'; import type { ElementType, ReactNode } from 'react'; import type { KnownHttpApiKey, PageAssets } from './preset/isomorphic/types'; -import type { HandlerContext } from './preset/node/types'; +import type { ExpressHandlerContext } from './preset/node/types'; import type { SpecificExtras } from './preset/node/handler/utils'; import type { CreateAxiosDefaults } from 'axios'; import type { AxiosInstanceWrapper, Middleware as AxiosMiddleware } from 'middleware-axios'; import type { CookieStore, Handler, LogHandler, LogHandlerFactory, Middleware } from './http'; import type { HttpApiHostPool } from './preset/isomorphic/utils'; +import type { ServerHandlerContext, ServerHandler, ServerMiddleware } from './preset/server/types'; export const KnownToken = { // config @@ -46,6 +47,7 @@ export const KnownToken = { // metrics Metrics: { httpApp: createToken('metrics/http-app'), + httpHandler: createToken('metrics/http-handler'), }, // http @@ -68,13 +70,13 @@ export const KnownToken = { serve: createToken('serve'), Serve: { - routes: createToken>('serve/routes'), - middleware: createToken('serve/middleware'), + routes: createToken>('serve/routes'), + middleware: createToken('serve/middleware'), }, Handler: { - main: createToken('handler/main'), - context: createToken<{ request: Request }>('handler/context'), + main: createToken('handler/main'), + context: createToken('handler/context'), Request: { specificParams: createToken>('request/specific-params'), }, @@ -118,7 +120,7 @@ export const KnownToken = { // express handler ExpressHandler: { main: createToken<() => void>('express-handler/main'), - context: createToken('express-handler/context'), + context: createToken('express-handler/context'), }, // redux