├── test ├── mocha.opts ├── gauge-test.ts ├── counter-test.ts ├── histogram-test.ts ├── registry-test.ts ├── utils-test.ts └── integration-test.ts ├── src ├── index.ts ├── gauge.ts ├── counter.ts ├── types.ts ├── collector.ts ├── histogram.ts ├── utils.ts └── registry.ts ├── .gitignore ├── .babelrc ├── LICENSE ├── tsconfig.json ├── webpack.config.js ├── .eslintrc ├── package.json └── README.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --require ts-node/register 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from './registry'; 2 | 3 | export * from './types'; 4 | 5 | export default () => new Registry(); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | lib/ 3 | 4 | # Coverage directory used by tools like istanbul 5 | coverage 6 | 7 | # Dependency directory 8 | node_modules 9 | npm-debug.log 10 | yarn-error.log 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | "@babel/proposal-class-properties", 8 | ["@babel/proposal-object-rest-spread", { "useBuiltIns": true }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/gauge.ts: -------------------------------------------------------------------------------- 1 | import { Labels } from './types'; 2 | import { Counter } from './counter'; 3 | 4 | export class Gauge extends Counter { 5 | dec(labels?: Labels): this { 6 | const metric = this.get(labels); 7 | this.set(metric ? metric.value - 1 : 0, labels); 8 | return this; 9 | } 10 | 11 | sub(amount: number, labels?: Labels): this { 12 | const metric = this.get(labels); 13 | this.set(metric ? metric.value - amount : 0, labels); 14 | return this; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Weaveworks. All rights reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /src/counter.ts: -------------------------------------------------------------------------------- 1 | import { Collector } from './collector'; 2 | import { CounterValue, Labels } from './types'; 3 | 4 | export class Counter extends Collector { 5 | inc(labels?: Labels): this { 6 | this.add(1, labels); 7 | return this; 8 | } 9 | 10 | add(amount: number, labels?: Labels): this { 11 | if (amount < 0) { 12 | throw new Error(`Expected increment amount to be greater than -1. Received: ${amount}`); 13 | } 14 | const metric = this.get(labels); 15 | this.set(metric ? metric.value + amount : amount, labels); 16 | 17 | return this; 18 | } 19 | 20 | reset(labels?: Labels): void { 21 | this.set(0, labels); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Target latest version of ECMAScript. 4 | "target": "esnext", 5 | // Process & infer types from .js files. 6 | "allowJs": false, 7 | // Import non-ES modules as default imports. 8 | "esModuleInterop": true, 9 | 10 | "outDir": "./lib/", // path to output directory 11 | // strict without alwaysStrict 12 | "noImplicitThis": true, 13 | "strictPropertyInitialization": true, 14 | "strictNullChecks": true, 15 | "noImplicitAny": true, 16 | "strictBindCallApply": true, 17 | "strictFunctionTypes": true, 18 | "declaration": true, 19 | "noUnusedLocals": true, 20 | "module": "commonjs", 21 | "baseUrl": "." 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "dist" 26 | ], 27 | "include": [ 28 | "./src/" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Counter } from './counter'; 2 | import { Gauge } from './gauge'; 3 | import { Registry } from './registry'; 4 | import { Histogram } from './histogram'; 5 | 6 | export type CollectorType = 'counter' | 'gauge' | 'histogram'; 7 | 8 | export type RegistryType = Registry; 9 | export type GaugeType = Gauge; 10 | export type CounterType = Counter; 11 | export type HistogramType = Histogram; 12 | 13 | export type CounterValue = number; 14 | export interface HistogramValueEntries { 15 | [key: string]: number; 16 | } 17 | 18 | export interface HistogramValue { 19 | entries: HistogramValueEntries; 20 | sum: number; 21 | count: number; 22 | raw: number[]; 23 | } 24 | 25 | export type MetricValue = CounterValue | HistogramValue; 26 | export interface Metric { 27 | value: T; 28 | labels?: Labels; 29 | } 30 | 31 | export interface Labels { 32 | [key: string]: string | number; 33 | } 34 | -------------------------------------------------------------------------------- /test/gauge-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Gauge } from '../src/gauge'; 3 | 4 | describe('Gauge', () => { 5 | let gauge: Gauge; 6 | 7 | beforeEach(() => { 8 | gauge = new Gauge(); 9 | }); 10 | 11 | it('sets the gauge', () => { 12 | const value = 55; 13 | 14 | expect(gauge.set(value).get()!.value).equals(value); 15 | }); 16 | it('increments and decrements values', () => { 17 | expect(gauge.inc().get()!.value).equals(1); 18 | expect(gauge.dec().get()!.value).equals(0); 19 | 20 | gauge.inc({ label: 'foo' }); 21 | gauge.dec({ label: 'foo' }); 22 | expect(gauge.collect().length).equals(2); 23 | }); 24 | 25 | it('adds and subtracts from values', () => { 26 | const amount = 10; 27 | const amountSub = 5; 28 | 29 | expect(gauge.add(amount).get()!.value).equals(amount); 30 | expect(gauge.sub(amountSub).get()!.value).equals(amount - amountSub); 31 | 32 | gauge.add(amount, { label: 'foo' }); 33 | gauge.sub(amountSub, { label: 'foo' }); 34 | 35 | expect(gauge.collect().length).equals(2); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require('terser-webpack-plugin') 2 | 3 | module.exports = (env, options) => { 4 | const LIBRARY_NAME = 'promjs'; 5 | 6 | const plugins = []; 7 | let mode; 8 | let outputFile; 9 | 10 | if (options.mode === 'production') { 11 | plugins.push(new TerserPlugin()); 12 | outputFile = `${LIBRARY_NAME}.min.js`; 13 | mode = 'production'; 14 | } else { 15 | outputFile = `${LIBRARY_NAME}.js`; 16 | mode = 'development'; 17 | } 18 | return { 19 | entry: `${__dirname}/src/index.ts`, 20 | mode, 21 | output: { 22 | path: `${__dirname}/lib/browser`, 23 | filename: outputFile, 24 | library: LIBRARY_NAME, 25 | libraryTarget: 'var' 26 | }, 27 | resolve: { 28 | extensions: ['.ts', '.js', '.json'] 29 | }, 30 | module: { 31 | rules: [{ 32 | test: /(\.ts)$/, 33 | loader: 'babel-loader', 34 | exclude: /(node_modules)/, 35 | query: { 36 | plugins: ['@babel/proposal-class-properties', '@babel/proposal-object-rest-spread'], 37 | presets: ['@babel/env'] 38 | } 39 | }] 40 | }, 41 | plugins 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /test/counter-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Counter } from '../src/counter'; 3 | 4 | describe('Counter', () => { 5 | let counter: Counter; 6 | 7 | beforeEach(() => { 8 | counter = new Counter(); 9 | }); 10 | 11 | it('increments a value', () => { 12 | expect(counter.inc().collect()).deep.equal([{ 13 | value: 1, 14 | labels: undefined, 15 | }]); 16 | }); 17 | 18 | it('adds a value', () => { 19 | const value = 5; 20 | 21 | expect(counter.add(value).collect()).deep.equal([{ 22 | value, 23 | labels: undefined, 24 | }]); 25 | }); 26 | 27 | it('increments a value with labels', () => { 28 | counter.inc({ path: '/foo/bar', status: 'fail' }); 29 | 30 | expect(counter.collect({ path: '/foo/bar', status: 'fail' })).deep.equal([{ 31 | labels: { 32 | path: '/foo/bar', 33 | status: 'fail', 34 | }, 35 | value: 1, 36 | }]); 37 | }); 38 | 39 | it('ensures value is >= 0', () => { 40 | const value = -5; 41 | const inc = counter.add.bind(counter, value); 42 | 43 | expect(inc).throw(); 44 | }); 45 | 46 | it('resets all data', () => { 47 | counter.inc({ path: '/foo/bar', status: 'fail' }); 48 | counter.resetAll(); 49 | 50 | expect(counter.collect({ path: '/foo/bar', status: 'fail' })).deep.equal([{ 51 | labels: { 52 | path: '/foo/bar', 53 | status: 'fail', 54 | }, 55 | value: 0, 56 | }]); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/collector.ts: -------------------------------------------------------------------------------- 1 | import { Labels, Metric, MetricValue } from './types'; 2 | import { findExistingMetric } from './utils'; 3 | 4 | export abstract class Collector { 5 | private readonly data: Metric[]; 6 | 7 | constructor() { 8 | this.data = []; 9 | } 10 | 11 | get(labels?: Labels): Metric | undefined { 12 | return findExistingMetric(labels, this.data); 13 | } 14 | 15 | set(value: T, labels?: Labels): this { 16 | const existing = findExistingMetric(labels, this.data); 17 | if (existing) { 18 | existing.value = value; 19 | } else { 20 | this.data.push({ 21 | labels, 22 | value, 23 | }); 24 | } 25 | 26 | return this; 27 | } 28 | 29 | collect(labels?: Labels): Metric[] { 30 | if (!labels) { 31 | return this.data; 32 | } 33 | return this.data.filter((item) => { 34 | if (!item.labels) { 35 | return false; 36 | } 37 | const entries = Object.entries(labels); 38 | for (let i = 0; i < entries.length; i += 1) { 39 | const [label, value] = entries[i]; 40 | if (item.labels[label] !== value) { 41 | return false; 42 | } 43 | } 44 | return true; 45 | }); 46 | } 47 | 48 | resetAll(): this { 49 | for (let i = 0; i < this.data.length; i += 1) { 50 | this.reset(this.data[i].labels); 51 | } 52 | 53 | return this; 54 | } 55 | 56 | abstract reset(labels?: Labels): void; 57 | } 58 | -------------------------------------------------------------------------------- /test/histogram-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Histogram } from '../src/histogram'; 3 | 4 | const buckets = [ 5 | 200, 6 | 400, 7 | 750, 8 | 1000, 9 | ]; 10 | 11 | describe('Histogram', () => { 12 | let histogram: Histogram; 13 | 14 | beforeEach(() => { 15 | histogram = new Histogram(buckets); 16 | }); 17 | 18 | it('observes some values', () => { 19 | histogram.observe(380); 20 | histogram.observe(400); 21 | histogram.observe(199); 22 | histogram.observe(1200); 23 | const result = histogram.collect(); 24 | 25 | expect(result.length).equals(1); 26 | expect(result[0].value).contains({ 27 | sum: 2179, 28 | count: 4, 29 | }); 30 | 31 | expect(result[0].value.entries).deep.equals({ 32 | 200: 1, 33 | 400: 3, 34 | 750: 3, 35 | 1000: 3, 36 | '+Inf': 4, 37 | }); 38 | }); 39 | 40 | it('clears observed values', () => { 41 | histogram.observe(380); 42 | histogram.observe(400); 43 | histogram.observe(199); 44 | histogram.reset(); 45 | 46 | const result = histogram.collect(); 47 | expect(result).deep.equal([{ 48 | labels: undefined, 49 | value: { 50 | sum: 0, 51 | count: 0, 52 | raw: [], 53 | entries: { 54 | 200: 0, 55 | 400: 0, 56 | 750: 0, 57 | 1000: 0, 58 | '+Inf': 0, 59 | }, 60 | }, 61 | }]); 62 | expect(result.length).equals(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-typescript", 4 | "plugin:@typescript-eslint/recommended" 5 | ], 6 | "plugins": [ 7 | "import", 8 | "@typescript-eslint" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "env": { 12 | "node": true, 13 | "browser": true, 14 | "mocha": true 15 | }, 16 | "parserOptions": { 17 | "ecmaVersion": 6, 18 | "sourceType": "module" 19 | }, 20 | "rules": { 21 | "semi": 2, 22 | "comma-dangle": 0, 23 | "global-require": 0, 24 | "import/no-extraneous-dependencies": [ 25 | "error", 26 | { 27 | "devDependencies": true, 28 | "optionalDependencies": true, 29 | "peerDependencies": true 30 | } 31 | ], 32 | "import/named": 0, 33 | "import/no-duplicates": 0, 34 | "@typescript-eslint/no-explicit-any": 0, 35 | "import/no-cycle": 0, 36 | "no-dupe-class-members": 0, 37 | "indent": "off", 38 | "@typescript-eslint/indent": [ 39 | "error", 40 | 2 41 | ], 42 | "@typescript-eslint/no-non-null-assertion": 0, 43 | "@typescript-eslint/explicit-member-accessibility": 0, 44 | "@typescript-eslint/no-object-literal-type-assertion": 0, 45 | "import/prefer-default-export": 0, 46 | "no-param-reassign": 0, 47 | "no-restricted-properties": 0, 48 | "object-curly-spacing": [ 49 | "error", 50 | "always" 51 | ], 52 | "object-curly-newline": [ 53 | "error", 54 | { 55 | "ObjectPattern": { 56 | "multiline": true 57 | }, 58 | "ImportDeclaration": "never", 59 | "ExportDeclaration": { 60 | "multiline": true, 61 | "minProperties": 3 62 | } 63 | } 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "promjs", 3 | "version": "0.4.2", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "typings": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "rm -rf lib && yarn run build:js && yarn run build:types && webpack-cli -p && cp package.json README.md ./lib", 9 | "build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline", 10 | "build:types": "tsc --emitDeclarationOnly", 11 | "lint": "eslint --ext .ts src test", 12 | "test": "mocha --recursive \"test/**/*-test.ts\"", 13 | "release": "yarn lint && yarn test && yarn version && yarn build && yarn push", 14 | "push": "cd lib && npm login && npm publish && git push --set-upstream origin master --follow-tags" 15 | }, 16 | "license": "Apache-2.0", 17 | "devDependencies": { 18 | "@babel/cli": "^7.2.3", 19 | "@babel/core": "^7.3.3", 20 | "@babel/plugin-proposal-class-properties": "^7.3.3", 21 | "@babel/plugin-proposal-object-rest-spread": "^7.3.2", 22 | "@babel/preset-env": "^7.3.1", 23 | "@babel/preset-typescript": "^7.3.3", 24 | "@types/chai": "^4.1.7", 25 | "@types/mocha": "^5.2.6", 26 | "@types/node": "^11.9.3", 27 | "@typescript-eslint/eslint-plugin": "^1.4.0", 28 | "@typescript-eslint/parser": "^1.4.0", 29 | "babel-loader": "^8.0.5", 30 | "chai": "^4.2.0", 31 | "eslint": "^5.16.0", 32 | "eslint-config-airbnb-typescript": "^1.1.0", 33 | "eslint-plugin-import": "^2.16.0", 34 | "eslint-plugin-jsx-a11y": "^6.2.1", 35 | "eslint-plugin-react": "^7.12.4", 36 | "mocha": "^4.1.0", 37 | "terser-webpack-plugin": "^4.2.3", 38 | "ts-node": "^8.0.2", 39 | "typescript": "^3.3.3", 40 | "webpack": "^4.42.1", 41 | "webpack-cli": "^3.3.11" 42 | }, 43 | "dependencies": {} 44 | } 45 | -------------------------------------------------------------------------------- /test/registry-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Collector } from '../src/collector'; 3 | import { Counter } from '../src/counter'; 4 | import { Registry } from '../src/registry'; 5 | 6 | describe('Registry', () => { 7 | let registry: Registry; 8 | let counter: Counter; 9 | 10 | beforeEach(() => { 11 | registry = new Registry(); 12 | counter = registry.create('counter', 'my_counter', 'A counter for things'); 13 | }); 14 | 15 | it('renders metrics to prometheus format', () => { 16 | let desired = '# HELP my_counter A counter for things\n'; 17 | desired += '# TYPE my_counter counter\n'; 18 | desired += 'my_counter 5\n'; 19 | 20 | counter.add(5); 21 | expect(registry.metrics()).equals(desired); 22 | }); 23 | 24 | it('renders metrics with labels to prometheus format', () => { 25 | let desired = '# HELP my_counter A counter for things\n'; 26 | desired += '# TYPE my_counter counter\n'; 27 | desired += 'my_counter{path="/org/:orgId",foo="bar"} 10\n'; 28 | 29 | counter.add(10, { path: '/org/:orgId', foo: 'bar' }); 30 | expect(registry.metrics()).equals(desired); 31 | }); 32 | 33 | it('clear all the metrics', () => { 34 | counter.inc(); 35 | registry.clear(); 36 | expect(registry.metrics()).equals(''); 37 | }); 38 | 39 | it('reset all the metrics', () => { 40 | counter.inc(); 41 | registry.reset(); 42 | expect(registry.metrics()).contains('my_counter 0'); 43 | counter.inc(); 44 | expect(registry.metrics()).contains('my_counter 1'); 45 | }); 46 | 47 | it('gets a metric by name and type', () => { 48 | const metric = registry.get('counter', 'my_counter'); 49 | expect(metric).instanceof(Collector); 50 | }); 51 | 52 | it('prevents naming collisions', () => { 53 | const dupe = (): void => { 54 | registry.create('counter', 'counter_a'); 55 | registry.create('counter', 'counter_a'); 56 | }; 57 | expect(dupe).throw(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/utils-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { formatCounterOrGauge, formatHistogramOrSummary } from '../src/utils'; 3 | 4 | describe('utils', () => { 5 | it('formats a counter/gauge metric', () => { 6 | const simple = { value: 2 }; 7 | const complex = { labels: { ok: 'true', status: 'success', code: 200 }, value: 1 }; 8 | 9 | expect(formatCounterOrGauge('my_counter', simple)).equals('my_counter 2\n'); 10 | expect(formatCounterOrGauge('my_counter', complex)).equals( 11 | 'my_counter{ok="true",status="success",code="200"} 1\n', 12 | ); 13 | }); 14 | 15 | it('formats a histogram metric', () => { 16 | let desired = ''; 17 | desired += 'my_histogram_count 2\n'; 18 | desired += 'my_histogram_sum 501\n'; 19 | desired += 'my_histogram_bucket{le="200"} 0\n'; 20 | desired += 'my_histogram_bucket{le="300"} 2\n'; 21 | desired += 'my_histogram_bucket{le="400"} 0\n'; 22 | desired += 'my_histogram_bucket{le="500"} 0\n'; 23 | const simple = { 24 | value: { 25 | sum: 501, 26 | count: 2, 27 | entries: { 200: 0, 300: 2, 400: 0, 500: 0 }, 28 | raw: [201, 300], 29 | }, 30 | }; 31 | const complex = { ...simple, labels: { instance: 'some_instance', ok: 'true' } }; 32 | expect(formatHistogramOrSummary('my_histogram', simple)).equals(desired); 33 | 34 | desired = 'my_histogram_count{instance="some_instance",ok="true"} 2\n'; 35 | desired += 'my_histogram_sum{instance="some_instance",ok="true"} 501\n'; 36 | desired += 'my_histogram_bucket{le="200",instance="some_instance",ok="true"} 0\n'; 37 | desired += 'my_histogram_bucket{le="300",instance="some_instance",ok="true"} 2\n'; 38 | desired += 'my_histogram_bucket{le="400",instance="some_instance",ok="true"} 0\n'; 39 | desired += 'my_histogram_bucket{le="500",instance="some_instance",ok="true"} 0\n'; 40 | 41 | expect(formatHistogramOrSummary('my_histogram', complex)).equals(desired); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/histogram.ts: -------------------------------------------------------------------------------- 1 | import { Collector } from './collector'; 2 | import { HistogramValue, HistogramValueEntries, Labels } from './types'; 3 | 4 | function findMinBucketIndex(ary: number[], num: number): number | undefined { 5 | if (num < ary[ary.length - 1]) { 6 | for (let i = 0; i < ary.length; i += 1) { 7 | if (num <= ary[i]) { 8 | return i; 9 | } 10 | } 11 | } 12 | 13 | return undefined; 14 | } 15 | 16 | function getInitialValue(buckets: number[]): HistogramValue { 17 | // Make the skeleton to which values will be saved. 18 | const entries = buckets.reduce((result, b) => { 19 | result[b.toString()] = 0; 20 | return result; 21 | }, { '+Inf': 0 } as HistogramValueEntries); 22 | 23 | return { 24 | entries, 25 | sum: 0, 26 | count: 0, 27 | raw: [], 28 | }; 29 | } 30 | 31 | export class Histogram extends Collector { 32 | private readonly buckets: number[]; 33 | 34 | constructor(buckets: number[] = []) { 35 | super(); 36 | // Sort to get smallest -> largest in order. 37 | this.buckets = buckets.sort((a, b) => (a > b ? 1 : -1)); 38 | this.set(getInitialValue(this.buckets)); 39 | this.observe = this.observe.bind(this); 40 | } 41 | 42 | observe(value: number, labels?: Labels): this { 43 | let metric = this.get(labels); 44 | if (metric == null) { 45 | // Create a metric for the labels. 46 | metric = this.set(getInitialValue(this.buckets), labels).get(labels)!; 47 | } 48 | 49 | metric.value.raw.push(value); 50 | metric.value.entries['+Inf'] += 1; 51 | 52 | const minBucketIndex = findMinBucketIndex(this.buckets, value); 53 | 54 | if (minBucketIndex != null) { 55 | for (let i = minBucketIndex; i < this.buckets.length; i += 1) { 56 | const val = metric.value.entries[this.buckets[i].toString()]; 57 | metric.value.entries[this.buckets[i].toString()] = val + 1; 58 | } 59 | } 60 | 61 | metric.value.sum = metric.value.raw.reduce((sum, v) => sum + v, 0); 62 | metric.value.count += 1; 63 | 64 | return this; 65 | } 66 | 67 | reset(labels?: Labels): void { 68 | this.set(getInitialValue(this.buckets), labels); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { HistogramValue, Labels, Metric, MetricValue } from './types'; 2 | 3 | function getLabelPairs(metric: Metric): string { 4 | const pairs = Object.entries(metric.labels || {}).map(([k, v]) => `${k}="${v}"`); 5 | return pairs.length === 0 ? '' : `${pairs.join(',')}`; 6 | } 7 | 8 | export function formatHistogramOrSummary( 9 | name: string, 10 | metric: Metric, 11 | bucketLabel = 'le', 12 | ): string { 13 | let str = ''; 14 | const labels = getLabelPairs(metric); 15 | if (labels.length > 0) { 16 | str += `${name}_count{${labels}} ${metric.value.count}\n`; 17 | str += `${name}_sum{${labels}} ${metric.value.sum}\n`; 18 | } else { 19 | str += `${name}_count ${metric.value.count}\n`; 20 | str += `${name}_sum ${metric.value.sum}\n`; 21 | } 22 | 23 | return Object.entries(metric.value.entries).reduce((result, [bucket, count]) => { 24 | if (labels.length > 0) { 25 | return `${result}${name}_bucket{${bucketLabel}="${bucket}",${labels}} ${count}\n`; 26 | } 27 | return `${result}${name}_bucket{${bucketLabel}="${bucket}"} ${count}\n`; 28 | }, str); 29 | } 30 | 31 | export function findExistingMetric( 32 | labels?: Labels, 33 | values: Metric[] = [], 34 | ): Metric | undefined { 35 | // If there are no labels, there can only be one metric 36 | if (!labels) { 37 | return values[0]; 38 | } 39 | return values.find((v) => { 40 | if (!v.labels) { 41 | return false; 42 | } 43 | if (Object.keys(v.labels || {}).length !== Object.keys(labels).length) { 44 | return false; 45 | } 46 | const entries = Object.entries(labels); 47 | for (let i = 0; i < entries.length; i += 1) { 48 | const [label, value] = entries[i]; 49 | if (v.labels[label] !== value) { 50 | return false; 51 | } 52 | } 53 | return true; 54 | }); 55 | } 56 | 57 | export function formatCounterOrGauge(name: string, metric: Metric): string { 58 | const value = ` ${metric.value.toString()}`; 59 | // If there are no keys on `metric`, it doesn't have a label; 60 | // return the count as a string. 61 | if (metric.labels == null || Object.keys(metric.labels).length === 0) { 62 | return `${name}${value}\n`; 63 | } 64 | const pair = Object.entries(metric.labels).map(([k, v]) => `${k}="${v}"`); 65 | return `${name}{${pair.join(',')}}${value}\n`; 66 | } 67 | -------------------------------------------------------------------------------- /test/integration-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import prom from '../src'; 3 | import { Registry } from '../src/registry'; 4 | 5 | describe('promjs', () => { 6 | // e2e Test 7 | let actual: string; 8 | let registry: Registry; 9 | let desired: string[]; 10 | let errors: string[]; 11 | 12 | beforeEach(() => { 13 | desired = []; 14 | registry = prom(); 15 | errors = []; 16 | const counter = registry.create('counter', 'my_counter', 'A counter for things'); 17 | const gauge = registry.create('gauge', 'my_gauge', 'A gauge for stuffs'); 18 | const histogram = registry.create('histogram', 'response_time', 'The response time', [ 19 | 200, 20 | 300, 21 | 400, 22 | 500, 23 | ]); 24 | 25 | desired.push('# HELP my_counter A counter for things\n'); 26 | desired.push('# TYPE my_counter counter\n'); 27 | counter.inc(); 28 | desired.push('my_counter 1\n'); 29 | 30 | counter.add(2, { ok: 'true', status: 'success', code: 200 }); 31 | counter.add(2, { ok: 'false', status: 'fail', code: 403 }); 32 | desired.push('my_counter{ok="true",status="success",code="200"} 2\n'); 33 | desired.push('my_counter{ok="false",status="fail",code="403"} 2\n'); 34 | 35 | desired.push('# HELP my_gauge A gauge for stuffs\n'); 36 | desired.push('# TYPE my_gauge gauge\n'); 37 | gauge.inc(); 38 | desired.push('my_gauge 1\n'); 39 | 40 | gauge.inc({ instance: 'some_instance' }); 41 | gauge.dec({ instance: 'some_instance' }); 42 | gauge.add(100, { instance: 'some_instance' }); 43 | gauge.sub(50, { instance: 'some_instance' }); 44 | desired.push('my_gauge{instance="some_instance"} 50\n'); 45 | 46 | desired.push('# HELP response_time The response time'); 47 | desired.push('# TYPE response_time histogram'); 48 | histogram.observe(299); 49 | histogram.observe(300); 50 | desired.push('response_time_count 2\n'); 51 | desired.push('response_time_sum 599\n'); 52 | desired.push('response_time_bucket{le="200"} 0\n'); 53 | desired.push('response_time_bucket{le="300"} 2\n'); 54 | desired.push('response_time_bucket{le="400"} 2\n'); 55 | desired.push('response_time_bucket{le="500"} 2\n'); 56 | desired.push('response_time_bucket{le="+Inf"} 2\n'); 57 | 58 | histogram.observe(401, { path: '/api/users', status: 200 }); 59 | histogram.observe(253, { path: '/api/users', status: 200 }); 60 | histogram.observe(499, { path: '/api/users', status: 200 }); 61 | histogram.observe(700, { path: '/api/users', status: 200 }); 62 | desired.push('response_time_bucket{le="200",path="/api/users",status="200"} 0\n'); 63 | desired.push('response_time_bucket{le="300",path="/api/users",status="200"} 1\n'); 64 | desired.push('response_time_bucket{le="400",path="/api/users",status="200"} 1\n'); 65 | desired.push('response_time_bucket{le="500",path="/api/users",status="200"} 3\n'); 66 | desired.push('response_time_bucket{le="+Inf",path="/api/users",status="200"} 4\n'); 67 | 68 | actual = registry.metrics(); 69 | }); 70 | 71 | it('reports metrics', () => { 72 | for (let i = 0; i < desired.length; i += 1) { 73 | const d = desired[i]; 74 | if (!actual.includes(d)) { 75 | errors.push(`Actual: ${actual}\nExpected: ${d}`); 76 | } 77 | } 78 | 79 | expect(errors).deep.equals([], errors.join('\n')); 80 | }); 81 | 82 | it('clear all metrics', () => { 83 | const cleared = registry.reset().metrics().split('\n'); 84 | 85 | // Check that the end of each metric string is a 0 86 | expect(cleared.length).greaterThan(5); 87 | for (let i = 0; i < cleared.length; i += 1) { 88 | const m = cleared[i]; 89 | if (m && !m.includes('TYPE') && !m.includes('HELP')) { 90 | expect(m.slice(-1)).equals('0'); 91 | } 92 | } 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | import { Collector } from './collector'; 2 | import { Counter } from './counter'; 3 | import { Gauge } from './gauge'; 4 | import { Histogram } from './histogram'; 5 | import { CollectorType, CounterValue, HistogramValue, Metric } from './types'; 6 | 7 | import { formatCounterOrGauge, formatHistogramOrSummary } from './utils'; 8 | 9 | type CollectorForType = 10 | T extends 'histogram' ? Histogram : 11 | T extends 'gauge' ? Gauge : 12 | T extends 'counter' ? Counter : 13 | never; 14 | 15 | interface RegistryItem { 16 | [key: string]: { 17 | type: T; 18 | help: string; 19 | instance: CollectorForType; 20 | }; 21 | } 22 | 23 | export class Registry { 24 | private data: { 25 | [K in CollectorType]: RegistryItem 26 | }; 27 | 28 | constructor() { 29 | this.data = { 30 | counter: {}, 31 | gauge: {}, 32 | histogram: {} 33 | }; 34 | } 35 | 36 | private validateInput( 37 | type: CollectorType, 38 | name: string, 39 | help?: string, 40 | buckets?: number[], 41 | ): void { 42 | // checks for js runtime 43 | if (String(name) === '') { 44 | throw new Error('Metric name cannot be empty'); 45 | } 46 | if (['counter', 'gauge', 'histogram'].indexOf(type) === -1) { 47 | throw new Error(`Unknown metric type ${type}`); 48 | } 49 | 50 | if (typeof help !== 'string' && help != null) { 51 | throw new Error('help must be string or undefined/null'); 52 | } 53 | 54 | if (this.data[type][name]) { 55 | throw new Error(`A metric with the name '${name}' already exists for type '${type}'`); 56 | } 57 | 58 | if (!Array.isArray(buckets) && buckets != null) { 59 | throw new Error('buckets must be array or undefined/null'); 60 | } 61 | } 62 | 63 | create(type: 'counter', name: string, help?: string): Counter; 64 | 65 | create(type: 'gauge', name: string, help?: string): Gauge; 66 | 67 | create(type: 'histogram', name: string, help?: string, histogramBuckets?: number[]): Histogram; 68 | 69 | create( 70 | type: CollectorType, 71 | name: string, 72 | help = '', 73 | histogramBuckets: number[] = [], 74 | ): Collector { 75 | this.validateInput(type, name, help, histogramBuckets); 76 | 77 | let instance; 78 | if (type === 'counter') { 79 | instance = new Counter(); 80 | this.data.counter[name] = { help, instance, type }; 81 | } else if (type === 'gauge') { 82 | instance = new Gauge(); 83 | this.data.gauge[name] = { help, instance, type }; 84 | } else { 85 | instance = new Histogram(histogramBuckets); 86 | this.data.histogram[name] = { help, instance, type }; 87 | } 88 | 89 | return instance; 90 | } 91 | 92 | /** 93 | * Returns a string in the prometheus' desired format 94 | * More info: https://prometheus.io/docs/concepts/data_model/ 95 | * Loop through each metric type (counter, histogram, etc); 96 | * 97 | * @return {string} 98 | */ 99 | metrics(): string { 100 | return Object.entries(this.data).reduce( 101 | (out, [type, metrics]) => out + Object.entries(metrics).reduce((src, [name, metric]) => { 102 | const values = metric.instance.collect(); 103 | let result = src; 104 | if (metric.help.length > 0) { 105 | result += `# HELP ${name} ${metric.help}\n`; 106 | } 107 | result += `# TYPE ${name} ${type}\n`; 108 | // Each metric can have many labels. Iterate over each and append to the string. 109 | result += values.reduce((str: string, value: any) => { 110 | const formatted = type === 'histogram' 111 | ? formatHistogramOrSummary(name, value as Metric) 112 | : formatCounterOrGauge(name, value as Metric); 113 | return str + formatted; 114 | }, ''); 115 | return result; 116 | }, ''), 117 | '' 118 | ); 119 | } 120 | 121 | reset(): this { 122 | Object.values(this.data).map(m => Object.values(m).map(({ instance }) => instance.resetAll())); 123 | return this; 124 | } 125 | 126 | clear(): this { 127 | this.data = { 128 | counter: {}, 129 | gauge: {}, 130 | histogram: {}, 131 | }; 132 | 133 | return this; 134 | } 135 | 136 | get(type: 'counter', name: string): Counter | undefined; 137 | 138 | get(type: 'gauge', name: string): Gauge | undefined; 139 | 140 | get(type: 'histogram', name: string): Histogram | undefined; 141 | 142 | get(type: CollectorType, name: string): Collector | undefined { 143 | const registryItems = type != null ? [this.data[type]] : Object.values(this.data); 144 | const metric = registryItems.find(v => name in v); 145 | 146 | return metric != null ? metric[name].instance : undefined; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED: promjs 2 | 3 | A Prometheus metrics registry implemented in TypeScript 4 | 5 | ## Goals 6 | 7 | - Stick to [Prometheus client best practices](https://prometheus.io/docs/instrumenting/writing_clientlibs/) as closely as possible 8 | - Run in Node.js or the browser 9 | - Fit into the modern JavaScript ecosystem 10 | - Minimally rely on third-party dependencies 11 | 12 | ## Installation 13 | 14 | Install via `npm`: 15 | 16 | `$ npm install --save promjs` 17 | 18 | or via `yarn`: 19 | 20 | `$ yarn add promjs` 21 | 22 | ## Usage 23 | 24 | ```javascript 25 | // Using es6 imports 26 | import prom from 'promjs'; 27 | // Using CommonJS 28 | const prom = require('promjs'); 29 | 30 | const registry = prom(); 31 | const pageRequestCounter = registry.create('counter', 'page_requests', 'A counter for page requests'); 32 | 33 | pageRequestCounter.inc(); 34 | console.log(registry.metrics()); 35 | // => 36 | // # HELP page_requests A counter for page requests \n 37 | // # TYPE page_requests counter 38 | // page_requests 1 \n 39 | ``` 40 | 41 | ## API 42 | 43 | ### prom() 44 | 45 | Returns a registry class. 46 | 47 | ## Registry 48 | 49 | ### registry.create(type, name, help) => collector (_counter | gauge | histogram_) 50 | 51 | Returns a metric class of the specified type. The metric is already registered with the registry that creates it. 52 | 53 | Arguments 54 | 55 | 1. `type` (_String_): The type of metric to create. The current supported types are `counter`, `gauge`, and `histogram`. 56 | 2. `name` (_String_): The name of the metric 57 | 3. `help` (_String_): The help message for the metric 58 | 59 | Example 60 | ```javascript 61 | import prom from 'promjs'; 62 | 63 | const registry = prom(); 64 | const counter = registry.create('counter', 'my_counter', 'A counter for things'); 65 | ``` 66 | 67 | ### registry.metrics() => string 68 | 69 | Returns a prometheus formatted string containing all existing metrics. 70 | 71 | ```javascript 72 | const counter = registry.create('counter', 'my_counter', 'A counter for things'); 73 | counter.inc(); 74 | console.log(registry.metrics()); 75 | // => 76 | // # HELP my_counter A counter for things \n 77 | // # TYPE my_counter counter 78 | // my_counter 1 \n 79 | ``` 80 | 81 | ### registry.clear() => self 82 | 83 | Removes all metrics from internal `data` storage. Returns itself to allow for chaining. 84 | 85 | ### registry.reset() => self 86 | 87 | Resets all existing metrics to 0. This can be used to reset metrics after reporting to a prometheus aggregator. Returns itself to allow for chaining. 88 | 89 | ### registry.get(type, name) => collector (_counter | gauge | histogram_) | null 90 | 91 | Fetches an existing metric by name. Returns null if no metrics are found 92 | 93 | ## Collector 94 | 95 | All of the metric classes (Counter, Gauge, Histogram) inherit from the Collector class. Collector methods are available on each of the metic classes. 96 | 97 | ### collector.reset([labels]) => self 98 | 99 | Resets metrics in the collector. Optionally pass in labels to reset only those labels. 100 | 101 | ### collector.resetAll() => self 102 | 103 | Resets all metrics in the collector, including metrics with labels. 104 | 105 | ## Counter 106 | 107 | A counter can only ever be incremented positively. 108 | 109 | ### counter.inc([labels]) => self 110 | 111 | Increments a counter. Optionally pass in a set of labels to increment only those labels. 112 | 113 | ### counter.add(amount, [labels]) => self 114 | 115 | Increments a counter by a given amount. `amount` must be a Number. Optionally pass in a set of labels to increment only those labels. 116 | 117 | ```javascript 118 | const counter = registry.create('counter', 'my_counter', 'A counter for things'); 119 | counter.inc(); 120 | counter.add(2, { ok: true, status: 'success', code: 200 }); 121 | counter.add(2, { ok: false, status: 'fail', code: 403 }); 122 | 123 | console.log(registry.metrics()); 124 | // => 125 | // # HELP my_counter A counter for things 126 | // # TYPE my_counter counter 127 | // my_counter 1 128 | // my_counter{ok="true",status="success",code="200"} 2 129 | // my_counter{ok="false",status="fail",code="403"} 2 130 | ``` 131 | 132 | ## Gauge 133 | 134 | A gauge is similar to a counter, but can be incremented up and down. 135 | 136 | ### gauge.inc([labels]) => self 137 | 138 | Increments a gauge by 1. 139 | 140 | ### gauge.dec([lables]) => self 141 | 142 | Decrements a gauge by 1. 143 | 144 | ### gauge.add(amount, [lables]) => self 145 | 146 | Increments a gauge by a given amount. `amount` must be a Number. 147 | 148 | ### gauge.sub(amount, [labels]) => self 149 | 150 | Decrements a gauge by a given amount. 151 | 152 | ```javascript 153 | const gauge = registry.create('gauge', 'my_gauge', 'A gauge for stuffs'); 154 | gauge.inc(); 155 | gauge.inc({ instance: 'some_instance' }); 156 | gauge.dec({ instance: 'some_instance' }); 157 | gauge.add(100, { instance: 'some_instance' }); 158 | gauge.sub(50, { instance: 'some_instance' }); 159 | 160 | console.log(registry.metrics()); 161 | // => 162 | // # HELP my_gauge A gauge for stuffs 163 | // # TYPE my_gauge gauge 164 | // my_gauge 1 165 | // my_gauge{instance="some_instance"} 50 166 | ``` 167 | 168 | ## Histogram 169 | 170 | Histograms are used to group values into pre-defined buckets. Buckets are passed in to the `registry.create()` call. 171 | 172 | ### histogram.observe(value) => self 173 | 174 | Adds `value` to a pre-existing bucket.`value` must be a number. 175 | 176 | ```javascript 177 | const histogram = registry.create('histogram', 'response_time', 'The response time', [ 178 | 200, 179 | 300, 180 | 400, 181 | 500 182 | ]); 183 | histogram.observe(299); 184 | histogram.observe(253, { path: '/api/users', status: 200 }); 185 | histogram.observe(499, { path: '/api/users', status: 200 }); 186 | 187 | console.log(registry.metrics()); 188 | // => 189 | // # HELP response_time The response time 190 | // # TYPE response_time histogram 191 | // response_time_count 3 192 | // response_time_sum 599 193 | // response_time_bucket{le="200"} 1 194 | // response_time_bucket{le="400",path="/api/users",status="200"} 1 195 | // response_time_bucket{le="200",path="/api/users",status="200"} 1 196 | ``` 197 | 198 | ## Getting Help 199 | 200 | If you have any questions about, feedback for or problems with `promjs`: 201 | 202 | - Invite yourself to the Weave Users Slack. 203 | - Ask a question on the [#general](https://weave-community.slack.com/messages/general/) slack channel. 204 | - [File an issue](https://github.com/weaveworks/promjs/issues/new). 205 | 206 | Weaveworks follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a Weaveworks project maintainer, or Alexis Richardson (alexis@weave.works). 207 | 208 | Your feedback is always welcome! 209 | --------------------------------------------------------------------------------