Skip to content

Commit

Permalink
fix: allow multiple consumers of metrics (#6)
Browse files Browse the repository at this point in the history
To support using labels as disambiguators across multiple reporters of
the same metric, cache metrics globally then return the pre-exsting
metric when it's registered on subsequent occasions.

Co-authored-by: Marin Petrunic <[email protected]>
  • Loading branch information
achingbrain and mpetrunic authored Nov 21, 2022
1 parent 123fb81 commit 92bde9b
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 32 deletions.
23 changes: 16 additions & 7 deletions src/counter-group.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
import type { CounterGroup, CalculateMetric } from '@libp2p/interface-metrics'
import { Counter as PromCounter, CollectFunction } from 'prom-client'
import { normaliseString, CalculatedMetric } from './utils.js'
import type { PrometheusCalculatedMetricOptions } from './index.js'
import { normaliseString } from './utils.js'

export class PrometheusCounterGroup implements CounterGroup {
export class PrometheusCounterGroup implements CounterGroup, CalculatedMetric<Record<string, number>> {
private readonly counter: PromCounter
private readonly label: string
private readonly calculators: Array<CalculateMetric<Record<string, number>>>

constructor (name: string, opts: PrometheusCalculatedMetricOptions<Record<string, number>>) {
name = normaliseString(name)
const help = normaliseString(opts.help ?? name)
const label = this.label = normaliseString(opts.label ?? name)
let collect: CollectFunction<PromCounter<any>> | undefined
this.calculators = []

// calculated metric
if (opts?.calculate != null) {
const calculate: CalculateMetric<Record<string, number>> = opts.calculate
this.calculators.push(opts.calculate)
const self = this

collect = async function () {
const values = await calculate()
await Promise.all(self.calculators.map(async calculate => {
const values = await calculate()

Object.entries(values).forEach(([key, value]) => {
this.inc({ [label]: key }, value)
})
Object.entries(values).forEach(([key, value]) => {
this.inc({ [label]: key }, value)
})
}))
}
}

Expand All @@ -35,6 +40,10 @@ export class PrometheusCounterGroup implements CounterGroup {
})
}

addCalculator (calculator: CalculateMetric<Record<string, number>>) {
this.calculators.push(calculator)
}

increment (values: Record<string, number | unknown>): void {
Object.entries(values).forEach(([key, value]) => {
const inc = typeof value === 'number' ? value : 1
Expand Down
20 changes: 14 additions & 6 deletions src/counter.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import type { Counter } from '@libp2p/interface-metrics'
import type { CalculateMetric, Counter } from '@libp2p/interface-metrics'
import { CollectFunction, Counter as PromCounter } from 'prom-client'
import type { PrometheusCalculatedMetricOptions } from './index.js'
import { normaliseString } from './utils.js'
import { normaliseString, CalculatedMetric } from './utils.js'

export class PrometheusCounter implements Counter {
export class PrometheusCounter implements Counter, CalculatedMetric {
private readonly counter: PromCounter
private readonly calculators: CalculateMetric[]

constructor (name: string, opts: PrometheusCalculatedMetricOptions) {
name = normaliseString(name)
const help = normaliseString(opts.help ?? name)
const labels = opts.label != null ? [normaliseString(opts.label)] : []
let collect: CollectFunction<PromCounter<any>> | undefined
this.calculators = []

// calculated metric
if (opts?.calculate != null) {
const calculate = opts.calculate
this.calculators.push(opts.calculate)
const self = this

collect = async function () {
const value = await calculate()
const values = await Promise.all(self.calculators.map(async calculate => await calculate()))
const sum = values.reduce((acc, curr) => acc + curr, 0)

this.inc(value)
this.inc(sum)
}
}

Expand All @@ -32,6 +36,10 @@ export class PrometheusCounter implements Counter {
})
}

addCalculator (calculator: CalculateMetric) {
this.calculators.push(calculator)
}

increment (value: number = 1): void {
this.counter.inc(value)
}
Expand Down
76 changes: 68 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { logger } from '@libp2p/logger'

const log = logger('libp2p:prometheus-metrics')

// prom-client metrics are global
const metrics = new Map<string, any>()

export interface PrometheusMetricsInit {
/**
* Use a custom registry to register metrics.
Expand Down Expand Up @@ -50,6 +53,7 @@ class PrometheusMetrics implements Metrics {

if (init?.preserveExistingMetrics !== true) {
log('Clearing existing metrics')
metrics.clear()
;(this.registry ?? register).clear()
}

Expand Down Expand Up @@ -140,8 +144,22 @@ class PrometheusMetrics implements Metrics {
throw new Error('Metric name is required')
}

let metric = metrics.get(name)

if (metrics.has(name)) {
log('Reuse existing metric', name)

if (opts.calculate != null) {
metric.addCalculator(opts.calculate)
}

return metrics.get(name)
}

log('Register metric', name)
const metric = new PrometheusMetric(name, { registry: this.registry, ...opts })
metric = new PrometheusMetric(name, { registry: this.registry, ...opts })

metrics.set(name, metric)

if (opts.calculate == null) {
return metric
Expand All @@ -152,14 +170,28 @@ class PrometheusMetrics implements Metrics {
registerMetricGroup (name: string, opts?: MetricOptions): MetricGroup
registerMetricGroup (name: string, opts: any = {}): any {
if (name == null ?? name.trim() === '') {
throw new Error('Metric name is required')
throw new Error('Metric group name is required')
}

let metricGroup = metrics.get(name)

if (metricGroup != null) {
log('Reuse existing metric group', name)

if (opts.calculate != null) {
metricGroup.addCalculator(opts.calculate)
}

return metricGroup
}

log('Register metric group', name)
const group = new PrometheusMetricGroup(name, { registry: this.registry, ...opts })
metricGroup = new PrometheusMetricGroup(name, { registry: this.registry, ...opts })

metrics.set(name, metricGroup)

if (opts.calculate == null) {
return group
return metricGroup
}
}

Expand All @@ -170,8 +202,22 @@ class PrometheusMetrics implements Metrics {
throw new Error('Counter name is required')
}

let counter = metrics.get(name)

if (counter != null) {
log('Reuse existing counter', name)

if (opts.calculate != null) {
counter.addCalculator(opts.calculate)
}

return metrics.get(name)
}

log('Register counter', name)
const counter = new PrometheusCounter(name, { registry: this.registry, ...opts })
counter = new PrometheusCounter(name, { registry: this.registry, ...opts })

metrics.set(name, counter)

if (opts.calculate == null) {
return counter
Expand All @@ -182,14 +228,28 @@ class PrometheusMetrics implements Metrics {
registerCounterGroup (name: string, opts?: MetricOptions): CounterGroup
registerCounterGroup (name: string, opts: any = {}): any {
if (name == null ?? name.trim() === '') {
throw new Error('Metric name is required')
throw new Error('Counter group name is required')
}

let counterGroup = metrics.get(name)

if (counterGroup != null) {
log('Reuse existing counter group', name)

if (opts.calculate != null) {
counterGroup.addCalculator(opts.calculate)
}

return counterGroup
}

log('Register counter group', name)
const group = new PrometheusCounterGroup(name, { registry: this.registry, ...opts })
counterGroup = new PrometheusCounterGroup(name, { registry: this.registry, ...opts })

metrics.set(name, counterGroup)

if (opts.calculate == null) {
return group
return counterGroup
}
}
}
Expand Down
23 changes: 16 additions & 7 deletions src/metric-group.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
import type { CalculateMetric, MetricGroup, StopTimer } from '@libp2p/interface-metrics'
import { CollectFunction, Gauge } from 'prom-client'
import type { PrometheusCalculatedMetricOptions } from './index.js'
import { normaliseString } from './utils.js'
import { normaliseString, CalculatedMetric } from './utils.js'

export class PrometheusMetricGroup implements MetricGroup {
export class PrometheusMetricGroup implements MetricGroup, CalculatedMetric<Record<string, number>> {
private readonly gauge: Gauge
private readonly label: string
private readonly calculators: Array<CalculateMetric<Record<string, number>>>

constructor (name: string, opts: PrometheusCalculatedMetricOptions<Record<string, number>>) {
name = normaliseString(name)
const help = normaliseString(opts.help ?? name)
const label = this.label = normaliseString(opts.label ?? name)
let collect: CollectFunction<Gauge<any>> | undefined
this.calculators = []

// calculated metric
if (opts?.calculate != null) {
const calculate: CalculateMetric<Record<string, number>> = opts.calculate
this.calculators.push(opts.calculate)
const self = this

collect = async function () {
const values = await calculate()
await Promise.all(self.calculators.map(async calculate => {
const values = await calculate()

Object.entries(values).forEach(([key, value]) => {
this.set({ [label]: key }, value)
})
Object.entries(values).forEach(([key, value]) => {
this.set({ [label]: key }, value)
})
}))
}
}

Expand All @@ -35,6 +40,10 @@ export class PrometheusMetricGroup implements MetricGroup {
})
}

addCalculator (calculator: CalculateMetric<Record<string, number>>) {
this.calculators.push(calculator)
}

update (values: Record<string, number>): void {
Object.entries(values).forEach(([key, value]) => {
this.gauge.set({ [this.label]: key }, value)
Expand Down
16 changes: 12 additions & 4 deletions src/metric.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import type { Metric, StopTimer } from '@libp2p/interface-metrics'
import type { Metric, StopTimer, CalculateMetric } from '@libp2p/interface-metrics'
import { CollectFunction, Gauge } from 'prom-client'
import type { PrometheusCalculatedMetricOptions } from './index.js'
import { normaliseString } from './utils.js'

export class PrometheusMetric implements Metric {
private readonly gauge: Gauge
private readonly calculators: CalculateMetric[]

constructor (name: string, opts: PrometheusCalculatedMetricOptions) {
name = normaliseString(name)
const help = normaliseString(opts.help ?? name)
const labels = opts.label != null ? [normaliseString(opts.label)] : []
let collect: CollectFunction<Gauge<any>> | undefined
this.calculators = []

// calculated metric
if (opts?.calculate != null) {
const calculate = opts.calculate
this.calculators.push(opts.calculate)
const self = this

collect = async function () {
const value = await calculate()
const values = await Promise.all(self.calculators.map(async calculate => await calculate()))
const sum = values.reduce((acc, curr) => acc + curr, 0)

this.set(value)
this.set(sum)
}
}

Expand All @@ -32,6 +36,10 @@ export class PrometheusMetric implements Metric {
})
}

addCalculator (calculator: CalculateMetric) {
this.calculators.push(calculator)
}

update (value: number): void {
this.gauge.set(value)
}
Expand Down
5 changes: 5 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type { CalculateMetric } from '@libp2p/interface-metrics'

export interface CalculatedMetric <T = number> {
addCalculator: (calculator: CalculateMetric<T>) => void
}

export const ONE_SECOND = 1000
export const ONE_MINUTE = 60 * ONE_SECOND
Expand Down
27 changes: 27 additions & 0 deletions test/counter-groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,31 @@ describe('counter groups', () => {

await expect(client.register.metrics()).to.eventually.not.include(metricKey, 'still included metric key')
})

it('should allow use of the same counter group from multiple reporters', async () => {
const metricName = randomMetricName()
const metricKey1 = randomMetricName('key_')
const metricKey2 = randomMetricName('key_')
const metricLabel = randomMetricName('label_')
const metricValue1 = 5
const metricValue2 = 7
const metrics = prometheusMetrics()()
const metric1 = metrics.registerCounterGroup(metricName, {
label: metricLabel
})
metric1.increment({
[metricKey1]: metricValue1
})
const metric2 = metrics.registerCounterGroup(metricName, {
label: metricLabel
})
metric2.increment({
[metricKey2]: metricValue2
})

const reportedMetrics = await client.register.metrics()

expect(reportedMetrics).to.include(`${metricName}{${metricLabel}="${metricKey1}"} ${metricValue1}`, 'did not include updated metric')
expect(reportedMetrics).to.include(`${metricName}{${metricLabel}="${metricKey2}"} ${metricValue2}`, 'did not include updated metric')
})
})
18 changes: 18 additions & 0 deletions test/counters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,22 @@ describe('counters', () => {

await expect(client.register.metrics()).to.eventually.include(`${metricName} 0`, 'did not include updated metric')
})

it('should allow use of the same counter from multiple reporters', async () => {
const metricName = randomMetricName()
const metricLabel = randomMetricName('label_')
const metricValue1 = 5
const metricValue2 = 7
const metrics = prometheusMetrics()()
const metric1 = metrics.registerCounter(metricName, {
label: metricLabel
})
metric1.increment(metricValue1)
const metric2 = metrics.registerCounter(metricName, {
label: metricLabel
})
metric2.increment(metricValue2)

await expect(client.register.metrics()).to.eventually.include(`${metricName} ${metricValue1 + metricValue2}`)
})
})
Loading

0 comments on commit 92bde9b

Please sign in to comment.