Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/app/metrics-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This package contains set of utilities to work with Prometheus metrics.
Table of contents:
1. [AbstractCounterMetric](#AbstractCounterMetric)
2. [AbstractHistogramMetric](#AbstractHistogramMetric)
3. [AbstractDimensionalCounterMetric](#AbstractDimensionalCounterMetric)
4. [AbstractDimensionalHistogramMetric](#AbstractDimensionalHistogramMetric)

### AbstractCounterMetric

Expand Down Expand Up @@ -82,3 +84,104 @@ const start = Date.now()
const end = Date.now()
requestDurationMetric.registerMeasurement({ route: '/api/users', startTime: start, endTime: end })
```

### AbstractDimensionalCounterMetric

A base class for counter metrics where dimensions are encoded in the metric name rather than as Prometheus labels. Each dimension becomes a separate metric record in the format `{namePrefix}_{dimension}:{nameSuffix}`.

Use this instead of `AbstractCounterMetric` when the tool consuming your metrics does not support Prometheus labels.

Usage:

```typescript
export class PizzaDeliveryCountMetric extends AbstractDimensionalCounterMetric<
['delivered_to_customer', 'delivered_to_pickup_point', 'not_delivered']
> {
constructor({ promClient }: Deps) {
super(
{
namePrefix: 'pizza_delivery',
nameSuffix: 'counter',
helpDescription: 'Number of pizza deliveries per status',
dimensions: ['delivered_to_customer', 'delivered_to_pickup_point', 'not_delivered'],
},
promClient,
)
}
}

const pizzaDeliveryCountMetric = new PizzaDeliveryCountMetric({ appMetrics })

pizzaDeliveryCountMetric.registerMeasurement({
delivered_to_customer: 1,
delivered_to_pickup_point: 2,
})
```

Where:
1. `namePrefix` - prefix of the metric name, before the dimension (e.g. `pizza_delivery`)
2. `nameSuffix` - suffix of the metric name, after the dimension (e.g. `counter`)
3. `helpDescription` - metric description
4. `dimensions` - the set of possible dimension values

Construction registers a separate label-free Prometheus Counter for each dimension, named `{namePrefix}_{dimension}:{nameSuffix}`. All dimensions are initialized to `0` on construction.

The above example registers the following metrics:
```text
pizza_delivery_delivered_to_customer:counter 0
pizza_delivery_delivered_to_pickup_point:counter 0
pizza_delivery_not_delivered:counter 0
```

`registerMeasurement` increments only the dimensions provided.

### AbstractDimensionalHistogramMetric

A base class for histogram metrics where dimensions are encoded in the metric name rather than as Prometheus labels. Each dimension becomes a separate label-free Prometheus Histogram in the format `{namePrefix}_{dimension}:{nameSuffix}`.

Use this instead of `AbstractHistogramMetric` when the tool consuming your metrics does not support Prometheus labels.

Usage:

```typescript
export class RequestDurationMetric extends AbstractDimensionalHistogramMetric<['successful', 'failed']> {
constructor({ promClient }: Deps) {
super(
{
namePrefix: 'request_duration',
nameSuffix: 'histogram',
helpDescription: 'Duration of requests in seconds',
dimensions: ['successful', 'failed'],
buckets: [0.1, 0.5, 1, 5],
},
promClient,
)
}
}

const requestDurationMetric = new RequestDurationMetric({ appMetrics })

// Record a duration directly:
requestDurationMetric.registerMeasurement('successful', { time: 0.32 })

// Or record using start and end times:
const start = Date.now()
// ... operation ...
const end = Date.now()
requestDurationMetric.registerMeasurement('failed', { startTime: start, endTime: end })
```

Where:
1. `namePrefix` - prefix of the metric name, before the dimension (e.g. `request_duration`)
2. `nameSuffix` - suffix of the metric name, after the dimension (e.g. `histogram`)
3. `helpDescription` - metric description
4. `dimensions` - the set of possible dimension values
5. `buckets` - histogram bucket boundaries

The above example registers the following metrics:
```text
request_duration_successful:histogram
request_duration_failed:histogram
```

`registerMeasurement` takes the dimension as its first argument and either a direct `time` value or a `startTime`/`endTime` pair.
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type promClient from 'prom-client'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { AbstractDimensionalCounterMetric } from './AbstractDimensionalCounterMetric.ts'

class ConcreteDimensionalCounterMetric extends AbstractDimensionalCounterMetric<
['successful', 'failed']
> {
constructor(client?: typeof promClient) {
super(
{
namePrefix: 'workflow_run:entitlements',
nameSuffix: 'counter',
helpDescription: 'Number of workflow runs per status',
dimensions: ['successful', 'failed'],
},
client,
)
}
}

describe('AbstractDimensionalCounterMetric', () => {
let incMock: Mock
let counterMock: Mock
let getSingleMetricMock: Mock
let client: typeof promClient

beforeEach(() => {
incMock = vi.fn()
// biome-ignore lint/complexity/useArrowFunction: required for vitest
counterMock = vi.fn().mockImplementation(function () {
return { inc: incMock }
})
getSingleMetricMock = vi.fn()
client = {
Counter: counterMock,
register: { getSingleMetric: getSingleMetricMock },
} as any as typeof promClient
})

describe('registerMeasurement', () => {
it('gracefully aborts if metrics are not enabled', () => {
// Given
const metric = new ConcreteDimensionalCounterMetric(undefined)

// When
metric.registerMeasurement({ successful: 20, failed: 10 })

// Then
expect(counterMock).not.toHaveBeenCalled()
expect(incMock).not.toHaveBeenCalled()
})

it('initializes correctly with 0 values for all dimensions', () => {
// Given
getSingleMetricMock.mockReturnValue(undefined)

// When
new ConcreteDimensionalCounterMetric(client)

// Then
expect(getSingleMetricMock).toHaveBeenCalledWith(
'workflow_run:entitlements_successful:counter',
)
expect(getSingleMetricMock).toHaveBeenCalledWith('workflow_run:entitlements_failed:counter')
expect(counterMock).toHaveBeenCalledWith({
name: 'workflow_run:entitlements_successful:counter',
help: 'Number of workflow runs per status',
labelNames: [],
})
expect(counterMock).toHaveBeenCalledWith({
name: 'workflow_run:entitlements_failed:counter',
help: 'Number of workflow runs per status',
labelNames: [],
})
expect(incMock).toHaveBeenCalledTimes(2)
expect(incMock).toHaveBeenCalledWith(0)
})

it('reuses existing metric per dimension when already registered', () => {
// Given
const existingCounter = { inc: incMock }
getSingleMetricMock.mockReturnValue(existingCounter)

// When
new ConcreteDimensionalCounterMetric(client)

// Then
expect(counterMock).not.toHaveBeenCalled()
expect(incMock).toHaveBeenCalledTimes(2)
expect(incMock).toHaveBeenCalledWith(0)
})

it('registers all measurements properly', () => {
// Given
getSingleMetricMock.mockReturnValue(undefined)
const metric = new ConcreteDimensionalCounterMetric(client)
incMock.mockClear()

// When
metric.registerMeasurement({ successful: 20, failed: 10 })

// Then
expect(incMock).toHaveBeenCalledTimes(2)
expect(incMock).toHaveBeenCalledWith(20)
expect(incMock).toHaveBeenCalledWith(10)
})

it('registers selected measurements only', () => {
// Given
getSingleMetricMock.mockReturnValue(undefined)
const metric = new ConcreteDimensionalCounterMetric(client)
incMock.mockClear()

// When
metric.registerMeasurement({ successful: 20 })

// Then
expect(incMock).toHaveBeenCalledTimes(1)
expect(incMock).toHaveBeenCalledWith(20)
})

it('should ignore measurements if client is not provided', () => {
// Given
const metric = new ConcreteDimensionalCounterMetric()

// When
metric.registerMeasurement({ successful: 20 })

// Then
expect(incMock).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type promClient from 'prom-client'
import type { Counter } from 'prom-client'

type DimensionalCounterMetricConfiguration<TDimensions extends string[]> = {
namePrefix: string
nameSuffix: string
helpDescription: string
dimensions: TDimensions
}

type DimensionalCounterMeasurement<TDimensions extends string[]> = Partial<
Record<TDimensions[number], number>
>

function buildDimensionalMetricName(
namePrefix: string,
dimension: string,
nameSuffix: string,
): string {
return `${namePrefix}_${dimension}:${nameSuffix}`
}

export abstract class AbstractDimensionalCounterMetric<TDimensions extends string[]> {
private readonly counters: Map<TDimensions[number], Counter>

protected constructor(
metricConfig: DimensionalCounterMetricConfiguration<TDimensions>,
client?: typeof promClient,
) {
this.counters = new Map()
if (!client) return

for (const dimension of metricConfig.dimensions) {
const name = buildDimensionalMetricName(
metricConfig.namePrefix,
dimension,
metricConfig.nameSuffix,
)
const existing = client.register.getSingleMetric(name)
const counter = existing
? (existing as Counter)
: new client.Counter({ name, help: metricConfig.helpDescription, labelNames: [] })
counter.inc(0)
this.counters.set(dimension, counter)
}
}

public registerMeasurement(measurement: DimensionalCounterMeasurement<TDimensions>): void {
if (this.counters.size === 0) return

for (const [dimension, value] of Object.entries(measurement) as [
TDimensions[number],
number,
][]) {
this.counters.get(dimension)?.inc(value)
}
}
}
Loading
Loading