diff --git a/src/benchttp/common.ts b/src/benchttp/common.ts index 4895c9d..b690113 100644 --- a/src/benchttp/common.ts +++ b/src/benchttp/common.ts @@ -6,19 +6,10 @@ export interface Statistics { min: number max: number mean: number - stdDev: number median: number + standardDeviation: number deciles: FixedArray | null quartiles: FixedArray | null } -export type RequestEvent = - | 'DNSDone' - | 'ConnectDone' - | 'TLSHandshakeDone' - | 'WroteHeaders' - | 'WroteRequest' - | 'GotFirstResponseByte' - | 'PutIdleConn' - export type Distribution = Record diff --git a/src/benchttp/field/core/field.ts b/src/benchttp/field/core/field.ts new file mode 100644 index 0000000..473baa5 --- /dev/null +++ b/src/benchttp/field/core/field.ts @@ -0,0 +1,42 @@ +import { + HTTPCodeKey, + IndexKey, + QuantileKey, + RequestEventKey, + RootKey, + StatisticsKey, +} from './key' + +export type FieldRepr = + | RequestCountField + | HTTPCodeDistributionField + | ResponseTimeStatisticsField + | RequestEventStatisticsField + +export type RequestCountField = + | RootKey.RequestCount + | RootKey.RequestFailureCount + | RootKey.RequestSuccessCount + +export type HTTPCodeDistributionField = Depth1< + RootKey.StatusCodesDistribution, + HTTPCodeKey +> + +export type ResponseTimeStatisticsField = StatisticsOf + +export type RequestEventStatisticsField = StatisticsOf< + Depth1 +> + +type StatisticsOf = + | Depth1 + | Depth2 + +type Depth1 = `${K0}.${K1}` + +type Depth2< + K0 extends string, + K1 extends string, + K2 extends string +> = `${Depth1}.${K2}` diff --git a/src/benchttp/field/core/key.ts b/src/benchttp/field/core/key.ts new file mode 100644 index 0000000..e0437d5 --- /dev/null +++ b/src/benchttp/field/core/key.ts @@ -0,0 +1,71 @@ +import { asInteger } from '@/lib/casting' +import { HTTPCode } from '@/typing' + +export type Key = + | RootKey + | StatisticsKey + | RequestEventKey + | IndexKey + | HTTPCodeKey + +export enum RootKey { + RequestCount = 'RequestCount', + RequestFailureCount = 'RequestFailureCount', + RequestSuccessCount = 'RequestSuccessCount', + RequestFailures = 'RequestFailures', + RequestEventTimes = 'RequestEventTimes', + ResponseTimes = 'ResponseTimes', + StatusCodesDistribution = 'StatusCodesDistribution', +} + +export enum StatisticsKey { + Min = 'Min', + Max = 'Max', + Mean = 'Mean', + Median = 'Median', + StandardDeviation = 'StandardDeviation', + Deciles = 'Deciles', + Quartiles = 'Quartiles', +} + +export type QuantileKey = StatisticsKey.Quartiles | StatisticsKey.Deciles + +export enum RequestEventKey { + DNSDone = 'DNSDone', + ConnectDone = 'ConnectDone', + TLSHandshakeDone = 'TLSHandshakeDone', + WroteHeaders = 'WroteHeaders', + WroteRequest = 'WroteRequest', + GotFirstResponseByte = 'GotFirstResponseByte', + BodyRead = 'BodyRead', + PutIdleConn = 'PutIdleConn', +} + +export type IndexKey = `${number}` + +export type HTTPCodeKey = HTTPCode + +// Key validators + +export const isOneOfKeys = ( + key: string, + keys: K +): key is K[number] => keys.includes(key as Key) + +export const isRootKey = (key: string): key is RootKey => + isOneOfKeys(key, Object.values(RootKey)) + +export const isStatisticsKey = (key: string): key is StatisticsKey => + isOneOfKeys(key, Object.values(StatisticsKey)) + +export const isQuantileKey = (key: string): key is QuantileKey => + isOneOfKeys(key, [StatisticsKey.Quartiles, StatisticsKey.Deciles]) + +export const isRequestEventKey = (key: string): key is RequestEventKey => + isOneOfKeys(key, Object.values(RequestEventKey)) + +export const isIndexKey = (key: string): key is IndexKey => + asInteger(key, (n) => n >= 0) + +export const isHTTPCodeKey = (key: string): key is HTTPCodeKey => + asInteger(key, (n) => 100 <= n && n <= 599) diff --git a/src/benchttp/field/core/node.ts b/src/benchttp/field/core/node.ts new file mode 100644 index 0000000..9a83fd2 --- /dev/null +++ b/src/benchttp/field/core/node.ts @@ -0,0 +1,86 @@ +import { memoize } from '@/lib/memoize' +import { Nullable } from '@/typing/nullable' + +import { + isHTTPCodeKey, + isIndexKey, + isRequestEventKey, + isRootKey, + isStatisticsKey, + isOneOfKeys, + RootKey, + StatisticsKey, + Key, +} from './key' + +export class FieldNode { + host: Nullable + key: string + + constructor({ host, key }: Pick) { + this.host = host + this.key = key + } + + get root(): FieldNode { + return memoize(this.#findRoot) + } + + get depth(): number { + return memoize(this.#computeDepth) + } + + #findRoot = () => this.#reduceHosts((_, host) => host as typeof this, this) + + #computeDepth = () => this.#reduceHosts((depth) => depth + 1, 0) + + #reduceHosts = (fn: (acc: T, host: FieldNode) => T, init: T): T => { + let accumulator: T = init + this.#forEachHost((host) => { + accumulator = fn(accumulator, host) + }) + return accumulator + } + + #forEachHost = (callback: (host: FieldNode) => void) => { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let current: FieldNode = this + while (current.host) { + current = current.host + callback(current) + } + return current + } + + public isValid = () => + this.isRoot() || + this.isStatistics() || + this.isRequestEvent() || + this.isIndex() || + this.isHTTPCode() + + private isRoot = () => isRootKey(this.key) && !this.host + + private isStatistics = () => + isStatisticsKey(this.key) && + (this.host?.is(RootKey.ResponseTimes) || this.host?.isRequestEvent()) + + private isRequestEvent = () => + isRequestEventKey(this.key) && this.host?.is(RootKey.RequestEventTimes) + + private isIndex = () => + isIndexKey(this.key) && + this.host?.isOneOf([ + RootKey.RequestFailures, + RootKey.RequestEventTimes, + StatisticsKey.Quartiles, + StatisticsKey.Deciles, + ]) + + private isHTTPCode = () => + isHTTPCodeKey(this.key) && this.host?.is(RootKey.StatusCodesDistribution) + + private is = (key: Key) => this.isOneOf([key]) + + private isOneOf = (keys: Key[]) => isOneOfKeys(this.key, keys) +} diff --git a/src/benchttp/field/core/parse.test.ts b/src/benchttp/field/core/parse.test.ts new file mode 100644 index 0000000..bb3af33 --- /dev/null +++ b/src/benchttp/field/core/parse.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'vitest' + +import { FieldRepr } from './field' +import { parseField } from './parse' + +describe('parseField', () => { + test('empty field', () => { + expectInvalidField('') + }) + + test('invalid root field', () => { + expectInvalidField('abc') + }) + + test('ResponseTimes', () => { + expectValidFields([ + 'ResponseTimes.Min', + 'ResponseTimes.Max', + 'ResponseTimes.Mean', + 'ResponseTimes.Median', + 'ResponseTimes.Deciles', + 'ResponseTimes.Deciles.1', + 'ResponseTimes.Quartiles', + 'ResponseTimes.Quartiles.2', + 'ResponseTimes.StandardDeviation', + ]) + expectInvalidFields([ + 'ResponseTimes.xxx', + 'ResponseTimes.Max.xxx', + 'ResponseTimes.1', + 'ResponseTimes.Max.1', + 'ResponseTimes.Deciles.-1', + ]) + }) + + test('RequestEventTimes', () => { + expectValidFields([ + 'RequestEventTimes.DNSDone.Min', + 'RequestEventTimes.ConnectDone.Max', + 'RequestEventTimes.TLSHandshakeDone.Mean', + 'RequestEventTimes.WroteHeaders.Median', + 'RequestEventTimes.WroteRequest.Deciles', + 'RequestEventTimes.GotFirstResponseByte.Quartiles', + 'RequestEventTimes.BodyRead.StandardDeviation', + 'RequestEventTimes.PutIdleConn.StandardDeviation', + ]) + expectInvalidFields([ + 'RequestEventTimes.xxx', + 'RequestEventTimes.ConnectDone.xxx', + 'RequestEventTimes.ConnectDone.Mean.xxx', + ]) + }) + + test('StatusCodesDistribution', () => { + expectValidFields([ + 'StatusCodesDistribution.101', + 'StatusCodesDistribution.200', + 'StatusCodesDistribution.302', + 'StatusCodesDistribution.418', + 'StatusCodesDistribution.500', + ]) + expectInvalidFields([ + 'StatusCodesDistribution.xxx', + 'StatusCodesDistribution.-1', + 'StatusCodesDistribution.99', + 'StatusCodesDistribution.600', + 'StatusCodesDistribution.200.xxx', + ]) + }) +}) + +const expectValidField = (field: FieldRepr) => { + const parsedField = parseField(field) + + expect(parsedField?.isValid()).toBeTruthy() + expect(parsedField?.root.key).toEqual(getRoot(field)) + expect(parsedField?.depth).toEqual(getDepth(field)) + if (hasParent(field)) { + expectValidField(getParent(field)) + } +} + +const expectValidFields = (fields: FieldRepr[]) => { + fields.forEach(expectValidField) +} + +const expectInvalidField = (field: string) => + // @ts-expect-error - testing context + expect(parseField(field)).toBeNull() + +const expectInvalidFields = (fields: string[]) => { + fields.forEach(expectInvalidField) +} + +const hasParent = (field: FieldRepr) => field.split('.').length > 1 + +const getParent = (field: FieldRepr): FieldRepr => + field.split('.').slice(0, -1).join('.') as FieldRepr + +const getDepth = (field: FieldRepr): number => field.split('.').length - 1 + +const getRoot = (field: FieldRepr) => field.split('.')[0] diff --git a/src/benchttp/field/core/parse.ts b/src/benchttp/field/core/parse.ts new file mode 100644 index 0000000..5e26e4c --- /dev/null +++ b/src/benchttp/field/core/parse.ts @@ -0,0 +1,28 @@ +import { Nullable } from '@/typing/nullable' + +import { FieldRepr } from './field' +import { FieldNode } from './node' + +export const parseField = (field: FieldRepr): Nullable => { + const stack = field.split('.') + return parseFieldRecursive(null, stack) +} + +function parseFieldRecursive( + currentField: Nullable, + stack: string[] +): Nullable { + const [nextKey, ...nextStack] = stack + const isLast = !nextKey + + if (isLast) { + return currentField + } + + const nextParsed = new FieldNode({ host: currentField, key: nextKey }) + if (!nextParsed.isValid()) { + return null + } + + return parseFieldRecursive(nextParsed, nextStack) +} diff --git a/src/benchttp/field/core/validator.ts b/src/benchttp/field/core/validator.ts new file mode 100644 index 0000000..d64f842 --- /dev/null +++ b/src/benchttp/field/core/validator.ts @@ -0,0 +1,74 @@ +import { Nullable } from '@/typing/nullable' + +import { + isHTTPCodeKey, + isIndexKey, + isQuantileKey, + isRootKey, + isStatisticsKey, +} from './key' + +type FieldValidator = + | RootFieldValidator + | StatisticsFieldValidator + | QuantileFieldValidator + | HTTPCodeFieldValidator + | IndexFieldValidator + +type FieldClass = + | typeof RootFieldValidator + | typeof StatisticsFieldValidator + | typeof QuantileFieldValidator + | typeof HTTPCodeFieldValidator + | typeof IndexFieldValidator + +interface IFieldValidator { + keyValidator: (key: string) => boolean + childValidators: FieldClass[] +} + +abstract class AbstractFieldValidator implements IFieldValidator { + key: string + child: Nullable + + constructor({ child, key }: Pick) { + this.child = child + this.key = key + } + + abstract keyValidator: IFieldValidator['keyValidator'] + + abstract childValidators: IFieldValidator['childValidators'] + + public readonly isValid = (): boolean => + this.keyValidator(this.key) && this.hasValidChild() + + private hasValidChild = (): boolean => + !this.child || + this.childValidators.some((Allowed) => this.child instanceof Allowed) +} + +export class QuantileFieldValidator extends AbstractFieldValidator { + keyValidator = isQuantileKey + childValidators = [IndexFieldValidator] +} + +export class StatisticsFieldValidator extends AbstractFieldValidator { + keyValidator = isStatisticsKey + childValidators = [QuantileFieldValidator] +} + +export class RootFieldValidator extends AbstractFieldValidator { + keyValidator = isRootKey + childValidators = [StatisticsFieldValidator] +} + +export class IndexFieldValidator extends AbstractFieldValidator { + keyValidator = isIndexKey + childValidators = [] +} + +export class HTTPCodeFieldValidator extends AbstractFieldValidator { + keyValidator = isHTTPCodeKey + childValidators = [IndexFieldValidator] +} diff --git a/src/benchttp/field/index.ts b/src/benchttp/field/index.ts new file mode 100644 index 0000000..7fb8da2 --- /dev/null +++ b/src/benchttp/field/index.ts @@ -0,0 +1,9 @@ +export { FieldNode } from './core/node' +export { + type RequestCountField, + type HTTPCodeDistributionField, + type RequestEventStatisticsField, + type ResponseTimeStatisticsField, +} from './core/field' +export { RequestEventKey } from './core/key' +export { parseField } from './core/parse' diff --git a/src/benchttp/metrics.ts b/src/benchttp/metrics.ts index 43fd93f..ad40eb1 100644 --- a/src/benchttp/metrics.ts +++ b/src/benchttp/metrics.ts @@ -1,6 +1,10 @@ -import { HTTPCode } from '@/typing' - -import { GoDuration, RequestEvent, Statistics } from './common' +import { GoDuration } from './common' +import { + RequestEventStatisticsField, + ResponseTimeStatisticsField, + HTTPCodeDistributionField, + RequestCountField, +} from './field' export type Metric = | ResponseTimeMetric @@ -12,26 +16,19 @@ export type NumberMetric = Extract export type DurationMetric = Extract -type RequestCountMetric = NumberMetricOf< - 'RequestCount' | 'RequestFailureCount' | 'RequestSuccessCount' -> +type RequestCountMetric = NumberMetricOf -type RequestEventMetric = DurationMetricOf< - StatisticsOf<`RequestEventTimes.${RequestEvent}`> -> +export type RequestEventMetric = DurationMetricOf -type ResponseTimeMetric = DurationMetricOf> +type ResponseTimeMetric = DurationMetricOf -type HTTPCodeDistributionMetric = - NumberMetricOf<`StatusCodesDistribution.${HTTPCode}`> +type HTTPCodeDistributionMetric = NumberMetricOf -interface SingleMetric { - field: Field - value: Type +interface SingleMetric { + field: F + value: T } -type DurationMetricOf = SingleMetric - -type NumberMetricOf = SingleMetric +type DurationMetricOf = SingleMetric -type StatisticsOf = `${T}.${Capitalize}` +type NumberMetricOf = SingleMetric diff --git a/src/benchttp/run.ts b/src/benchttp/run.ts index dee408d..1fb6083 100644 --- a/src/benchttp/run.ts +++ b/src/benchttp/run.ts @@ -1,7 +1,7 @@ import { HTTPCode } from '@/typing' -import { Distribution, RequestEvent, Statistics } from './common' -import { RunConfiguration } from './configuration' +import { Distribution, Statistics } from './common' +import { RequestEventKey } from './field' import { Metric } from './metrics' import { TestPredicate } from './tests' @@ -20,7 +20,7 @@ export interface RunProgress { export interface RunReport { metrics: { responseTimes: Statistics - requestEventTimes: Record + requestEventTimes: Record statusCodesDistribution: Distribution records: { responseTime: number }[] requestFailures: { reason: string }[] @@ -34,7 +34,6 @@ export interface RunReport { }[] } metadata: { - config: RunConfiguration startedAt: number finishedAt: number } diff --git a/src/lib/casting.ts b/src/lib/casting.ts new file mode 100644 index 0000000..7826d6a --- /dev/null +++ b/src/lib/casting.ts @@ -0,0 +1,11 @@ +/** + * Casts `v` to an integer. If successful, calls `fn` with the casted integer + * and returns the resulting value. + * + * @example + * asInteger('2', (n) => 2 * n) // 4 + */ +export const asInteger = (v: unknown, fn: (n: number) => T) => { + const n = Number(v) + return Number.isInteger(n) && fn(n) +} diff --git a/src/lib/memoize.ts b/src/lib/memoize.ts new file mode 100644 index 0000000..3408fee --- /dev/null +++ b/src/lib/memoize.ts @@ -0,0 +1,4 @@ +const cache = new WeakMap() + +export const memoize = (fn: () => T): T => + cache.get(fn) ?? cache.set(fn, fn()).get(fn) diff --git a/src/typing/http.ts b/src/typing/http.ts index 2b38f1a..12af1e9 100644 --- a/src/typing/http.ts +++ b/src/typing/http.ts @@ -1,3 +1,3 @@ import { Digit } from './digit' -export type HTTPCode = `${'' | '-'}${Digit}${Digit}${Digit}` +export type HTTPCode = `${Extract}${Digit}${Digit}` diff --git a/src/typing/nullable.ts b/src/typing/nullable.ts new file mode 100644 index 0000000..36fa025 --- /dev/null +++ b/src/typing/nullable.ts @@ -0,0 +1 @@ +export type Nullable = T | null