Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AP-5078 prometheus counter transaction manager #192

Merged
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ Add the plugin to your Fastify instance by registering it with the following opt

The plugin exposes a `GET /metrics` route in your Fastify app to retrieve Prometheus metrics. If something goes wrong while starting the Prometheus metrics server, an `Error` is thrown. Otherwise, a success message is displayed when the plugin has been loaded.

#### `PrometheusCounterTransactionManager`

`PrometheusCounterTransactionManager` is an implementation of `TransactionObservabilityManager` that uses Prometheus
counters to track the number of started, failed, and successful transactions. The results are automatically added to
the `/metrics` endpoint exposed by the metrics plugin.


### BullMQ Metrics Plugin

Plugin to auto-discover BullMQ queues which can regularly collect metrics for them and expose via `fastify-metrics` global Prometheus registry. If used together with `metricsPlugin`, it will show these metrics on `GET /metrics` route.
Expand Down
23 changes: 8 additions & 15 deletions lib/plugins/bullMqMetricsPlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@lokalise/background-jobs-common'
import type { FastifyInstance } from 'fastify'
import fastify from 'fastify'
import type Redis from 'ioredis'

import { TestBackgroundJobProcessor } from '../../test/mocks/TestBackgroundJobProcessor'
import { TestDepedendencies } from '../../test/mocks/TestDepedendencies'
Expand Down Expand Up @@ -72,14 +71,14 @@ describe('bullMqMetricsPlugin', () => {
let dependencies: TestDepedendencies
let processor: AbstractBackgroundJobProcessor<BaseJobPayload, JobReturn>
let redisConfig: RedisConfig
let redis: Redis

beforeEach(async () => {
dependencies = new TestDepedendencies()
redisConfig = dependencies.getRedisConfig()

redis = createSanitizedRedisClient(redisConfig)
await redis.flushall()
const redis = createSanitizedRedisClient(redisConfig)
await redis.flushall('SYNC')
await redis.quit()

processor = new TestBackgroundJobProcessor<BaseJobPayload, JobReturn>(
dependencies.createMocksForBackgroundJobProcessor(),
Expand All @@ -90,11 +89,8 @@ describe('bullMqMetricsPlugin', () => {
})

afterEach(async () => {
if (app) {
await app.close()
}
await redis.quit()
await processor.dispose()
if (app) await app.close()
})

it('throws if fastify-metrics was not initialized', async () => {
Expand Down Expand Up @@ -160,15 +156,12 @@ describe('bullMqMetricsPlugin', () => {
'bullmq_jobs_finished_duration_count{status="completed",queue="test_job"}',
)

await processor.schedule({
metadata: {
correlationId: 'test',
},
const jobId = await processor.schedule({
metadata: { correlationId: 'test' },
})

await setTimeout(100)

await app.bullMqMetrics.collect()
await processor.spy.waitForJobWithId(jobId, 'completed')
await setTimeout(200)

const responseAfter = await getMetrics()
expect(responseAfter.result.body).toContain(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { TEST_OPTIONS, buildClient, sendGet } from '@lokalise/backend-http-client'
import fastify, { type FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { metricsPlugin } from '../metricsPlugin'
import { PrometheusCounterTransactionManager } from './PrometheusCounterTransactionManager'

describe('PrometheusCounterTransactionManager', () => {
describe('appMetrics is undefined', () => {
it('should not throw errors', () => {
const transactionManager = new PrometheusCounterTransactionManager('test', 'test', undefined)
transactionManager.start('test', 'test')
transactionManager.stop('test')
transactionManager.startWithGroup('test2', 'test2', 'test2')
transactionManager.stop('test2', false)
})
})

describe('appMetrics is defined', () => {
const initApp = async () => {
const app = fastify()
await app.register(metricsPlugin, {
bindAddress: '0.0.0.0',
loggerOptions: false,
errorObjectResolver: () => undefined,
})

await app.ready()
return app
}

let app: FastifyInstance

beforeAll(async () => {
app = await initApp()
})

afterAll(async () => {
await app.close()
})

it('returns counter metrics', async () => {
const counterManager = new PrometheusCounterTransactionManager(
'myMetric',
'this is my first metric',
app.metrics,
)

counterManager.start('myTransaction', 'myKey1')
counterManager.stop('myKey1')

counterManager.startWithGroup('myTransaction', 'myKey2', 'group1')
counterManager.stop('myKey2', true)

counterManager.start('myTransaction', 'myKey3')
counterManager.stop('myKey3', false)

const response = await sendGet(buildClient('http://127.0.0.1:9080'), '/metrics', TEST_OPTIONS)
expect(response.result.statusCode).toBe(200)
expect(response.result.body).toContain(
[
'# HELP myMetric this is my first metric',
'# TYPE myMetric counter',
'myMetric{status="started",transactionName="myTransaction"} 3',
'myMetric{status="success",transactionName="myTransaction"} 2',
'myMetric{status="failed",transactionName="myTransaction"} 1',
].join('\n'),
)

// registering metric with same name but different description -> should be the same metric
const counterManager2 = new PrometheusCounterTransactionManager(
'myMetric',
'this is my second metric',
app.metrics,
)
counterManager2.start('myTransaction', 'myKey4')
counterManager2.stop('myKey4', false)

const response2 = await sendGet(
buildClient('http://127.0.0.1:9080'),
'/metrics',
TEST_OPTIONS,
)
expect(response2.result.statusCode).toBe(200)
expect(response2.result.body).toContain(
[
'# HELP myMetric this is my first metric',
'# TYPE myMetric counter',
'myMetric{status="started",transactionName="myTransaction"} 4',
'myMetric{status="success",transactionName="myTransaction"} 2',
'myMetric{status="failed",transactionName="myTransaction"} 2',
].join('\n'),
)
})
})
})
59 changes: 59 additions & 0 deletions lib/plugins/prometheus/PrometheusCounterTransactionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { TransactionObservabilityManager } from '@lokalise/node-core'
import type { IFastifyMetrics } from 'fastify-metrics'
import type { Counter } from 'prom-client'

/**
* TransactionObservabilityManager implementation that uses Prometheus counter
* to track the number of started, failed and success transactions.
*/
export class PrometheusCounterTransactionManager implements TransactionObservabilityManager {
private readonly metricName: string
private readonly metricDescription: string
private readonly counter?: Counter<'status' | 'transactionName'>

private readonly transactionNameByKey: Map<string, string> = new Map()

constructor(metricName: string, metricDescription: string, appMetrics?: IFastifyMetrics) {
this.metricName = metricName
this.metricDescription = metricDescription
this.counter = this.registerMetric(appMetrics)
}

start(transactionName: string, uniqueTransactionKey: string): void {
this.transactionNameByKey.set(uniqueTransactionKey, transactionName)
this.counter?.inc({ status: 'started', transactionName: transactionName })
}

startWithGroup(
transactionName: string,
uniqueTransactionKey: string,
_transactionGroup: string,
): void {
this.transactionNameByKey.set(uniqueTransactionKey, transactionName)
this.counter?.inc({ status: 'started', transactionName })
}

stop(uniqueTransactionKey: string, wasSuccessful = true): void {
const transactionName = this.transactionNameByKey.get(uniqueTransactionKey)
if (!transactionName) return

this.counter?.inc({ status: wasSuccessful ? 'success' : 'failed', transactionName })
this.transactionNameByKey.delete(uniqueTransactionKey)
}

private registerMetric(appMetrics?: IFastifyMetrics) {
if (!appMetrics) return

const existingMetric: Counter | undefined = appMetrics.client.register.getSingleMetric(
this.metricName,
) as Counter | undefined

if (existingMetric) return existingMetric

return new appMetrics.client.Counter({
name: this.metricName,
help: this.metricDescription,
labelNames: ['status', 'transactionName'],
})
}
}
31 changes: 10 additions & 21 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,9 @@
"type": "git",
"url": "git://github.com/lokalise/fastify-extras.git"
},
"keywords": [
"fastify",
"newrelic",
"bugsnag",
"request-context",
"request-id",
"split-io"
],
"keywords": ["fastify", "newrelic", "bugsnag", "request-context", "request-id", "split-io"],
"homepage": "https://github.com/lokalise/fastify-extras",
"files": [
"dist/**",
"LICENSE",
"README.md"
],
"files": ["dist/**", "LICENSE", "README.md"],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "commonjs",
Expand All @@ -40,18 +29,18 @@
"lint:fix": "biome check --write",
"docker:start": "docker compose -f docker-compose.yml up --build -d redis && docker compose -f docker-compose.yml up --build -d wait_for_redis",
"docker:stop": "docker compose -f docker-compose.yml down",
"version": "auto-changelog -p && git add CHANGELOG.md",
"postversion": "biome check --write package.json"
"version": "auto-changelog -p && git add CHANGELOG.md && biome check --write package.json && git add package.json"
},
"dependencies": {
"@amplitude/analytics-node": "^1.3.6",
"@bugsnag/js": "^7.25.0",
"@supercharge/promise-pool": "^3.2.0",
"@lokalise/error-utils": "^2.0.0",
"@lokalise/background-jobs-common": "^7.6.0",
"@lokalise/error-utils": "^2.0.0",
"@splitsoftware/splitio": "^10.27.0",
"@amplitude/analytics-node": "^1.3.6",
"@supercharge/promise-pool": "^3.2.0",
"fastify-metrics": "^11.0.0",
"fastify-plugin": "^4.5.1",
"prom-client": "^15.1.3",
"toad-cache": "^3.7.0",
"tslib": "^2.7.0"
},
Expand All @@ -66,18 +55,18 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@lokalise/backend-http-client": "^2.2.0",
"@amplitude/analytics-types": "^2.8.0",
"@biomejs/biome": "^1.8.3",
"@lokalise/backend-http-client": "^2.2.0",
"@lokalise/biome-config": "^1.4.0",
"@lokalise/node-core": "^12.0.0",
"@types/newrelic": "^9.14.4",
"@types/node": "^22.5.0",
"@amplitude/analytics-types": "^2.8.0",
"@vitest/coverage-v8": "^2.0.5",
"auto-changelog": "^2.4.0",
"bullmq": "^5.12.10",
"ioredis": "^5.4.1",
"fastify": "^4.28.1",
"ioredis": "^5.4.1",
"newrelic": "12.2.0",
"pino": "^9.3.2",
"pino-pretty": "^11.2.2",
Expand Down
Loading