Skip to content

Commit

Permalink
feat: add single session metric, keep metrics in memory until next sc…
Browse files Browse the repository at this point in the history
…rape
  • Loading branch information
stfsy committed Jan 6, 2023
1 parent 261b2ec commit e51cda1
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 158 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ This is a simple server that scrapes the output of the unix command `w`. [w disp

**Why would you want to do that?** This exporter will allow you to monitor who logs into your system. So you can define alarms for unauthorized users, the max. amount of active session or even every active user session. **`W` will also monitor logins via SSH which allows you to track even active remote sessions.**

Currently, two metrics are created and exposed:
Currently, three metrics are created and exposed:

- what_up{version="x.x.x"} 1
- what_user_sessions_currently_active{user="demo"} 1
- `Status of the exporter`: what_up{version="x.x.x"} 1
- `Sum of session per user`: what_user_sessions_currently_active{user="demo"} 1
- `Single sessions per user`: what_each_session_currently_active{user="pip3",ip="192.168.2.107",tty="pts/0"} 1

The exporter was tested on Ubuntu.

Expand Down
73 changes: 0 additions & 73 deletions lib/expiring-array-adapter.js

This file was deleted.

38 changes: 28 additions & 10 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
const telemetry = require('@opentelemetry/api-metrics');
const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus')
const { MeterProvider } = require('@opentelemetry/sdk-metrics-base')
const { createHash } = require('crypto')

const ExpiringArray = require('./expiring-array-adapter')

const pkg = require('../package.json')

Expand Down Expand Up @@ -49,8 +46,8 @@ let up = 1
let aggregatedUserSessions = {}

meter.getMeter('default').createObservableGauge('user_sessions_currently_active', {
description: 'Currently logged in users',
constantLabels: ['user', 'ip']
description: 'Sum of sessions per user',
constantLabels: ['user']
}).addCallback((observer) => {
if (!aggregatedUserSessions) {
return
Expand All @@ -62,6 +59,25 @@ meter.getMeter('default').createObservableGauge('user_sessions_currently_active'
})
})

meter.getMeter('default').createObservableGauge('each_session_currently_active', {
description: 'Single sessions per user',
constantLabels: ['user', 'ip', 'tty']
}).addCallback((observer) => {
if (!aggregatedUserSessions) {
return
}
Object.entries(aggregatedUserSessions).forEach(([key, value]) => {
value.forEach((session) => {
const { from, tty } = session
observer.observe(1, {
user: key,
ip: from,
tty
})
})
})
})

meter.getMeter('default').createObservableGauge('up', {
description: 'Health and status of the exporter',
constantLabels: ['version']
Expand All @@ -77,8 +93,8 @@ const lookup = async () => {
up = 1
const result = parser(wResult)

const sessionsByUser = result.map(({ user, 'login@': login, from }) => {
return Object.assign({}, { user, login, from })
const sessionsByUser = result.map(({ user, 'login@': login, from, tty }) => {
return Object.assign({}, { user, login, from, tty })
}).reduce((context, next) => {
if (!Array.isArray(context[next.user])) {
context[next.user] = []
Expand All @@ -87,14 +103,16 @@ const lookup = async () => {
return context
}, {})

aggregatedUserSessions = {}
// update sessions with fresh data
// will override current data of active sessions
Object.entries(sessionsByUser).forEach(([key, value]) => {
if (!Object.prototype.hasOwnProperty.call(aggregatedUserSessions, key)) {
aggregatedUserSessions[key] = new ExpiringArray(retention)
if (!Array.isArray(aggregatedUserSessions[key])) {
aggregatedUserSessions[key] = []
}
aggregatedUserSessions[key].push(createHash('sha256').update(JSON.stringify(value)).digest().toString('base64'))
aggregatedUserSessions[key].push(...value)
})

} catch (e) {
up = 0
console.log(`Could not gather metrics. Will retry after ${interval} ms`, e)
Expand Down
14 changes: 0 additions & 14 deletions lib/interval-wrapper.js

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"dev": "nodemon npm start",
"start": "node lib/index",
"test": "mocha test/spec --check-leaks --timeout 10000 --file test/after-all.js",
"test": "mocha test/spec --check-leaks --timeout 10000",
"nlv": "node-license-validator --allow-licenses WTFPL ISC MIT Apache-2.0 --allow-packages --production --deep",
"coverage": "nyc npm run test",
"docs": "jsdoc lib --destination docs --configure .jsdoc.json --readme README.md ",
Expand Down
7 changes: 0 additions & 7 deletions test/after-all.js

This file was deleted.

29 changes: 0 additions & 29 deletions test/spec/expiring-array-adapter.spec.js

This file was deleted.

75 changes: 54 additions & 21 deletions test/spec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ require.cache[wResolvedPath] = {
pip pts/0 192.168.2.107 23:50 1.00s 0.15s 0.01s w
`)
} else if (returnOnePip3) {
console.log('return pip3')
return Promise.resolve(`23:50:13 up 1 day, 11:33, 1 user, load average: 0.08, 0.03, 0.01
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
pip3 pts/0 192.168.2.107 23:50 1.00s 0.15s 0.01s w
Expand All @@ -38,13 +39,46 @@ require.cache[wResolvedPath] = {
} else {
return Promise.resolve(`23:50:13 up 1 day, 11:33, 1 user, load average: 0.08, 0.03, 0.01
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
pip pts/0 192.168.2.107 23:50 1.00s 0.15s 0.01s w
pip pts/0 192.168.2.107 23:51 1.00s 0.15s 0.01s w
pip pts/1 192.168.2.107 23:50 1.00s 0.15s 0.01s ls
pipX pts/2 44.33.22.1 23:49 1.00s 0.15s 0.01s w
`)
}
}
}

const waitAndExpectMetricStrings = (waitMillis, ...expectedMetric) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
http.get('http://localhost:9839/metrics', (response) => {
if (response.statusCode !== 200) {
reject(response.statusCode)
return
}
const responseData = []
response.on('data', (data) => {
responseData.push(data.toString('ascii'))
})
response.on('end', () => {
const body = responseData.join()

expectedMetric.forEach((metric) => {
try {
expect(body).to.contain(metric)
} catch (e) {
console.error('Assertion error.', 'Full response was...')
console.error(body)
throw e
}
})
resolve()
})
})
}, waitMillis)
})
}

const waitAndDoNotExpectMetricStrings = (waitMillis, ...expectedMetric) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
http.get('http://localhost:9839/metrics', (response) => {
Expand All @@ -59,7 +93,13 @@ const waitAndExpectMetricStrings = (waitMillis, ...expectedMetric) => {
response.on('end', () => {
const body = responseData.join()
expectedMetric.forEach((metric) => {
expect(body).to.contain(metric)
try {
expect(body).to.not.contain(metric)
} catch (e) {
console.error('Assertion error.', 'Full response was...')
console.error(body)
throw e
}
})
resolve()
})
Expand Down Expand Up @@ -87,31 +127,24 @@ describe('WhatActiveUsersExporter', () => {
})

it('returns one active sessions', () => {
throwError = false
return waitAndExpectMetricStrings(500, 'user_sessions_currently_active{user="pip"} 1')
})

it('keeps metrics until retention period', () => {
// retention period is 1s
// scrape interval is 250ms
// => metrics should at least be stored for less than 1s, thus we wait 750ms
returnTwoPips = false
returnOnePip3 = true
return waitAndExpectMetricStrings(500, 'user_sessions_currently_active{user="pip"} 1')
throwError = false
return waitAndExpectMetricStrings(500, 'user_sessions_currently_active{user="pip3"} 1')
})

it('removes metric value for inactive sessions after retention period', () => {
// retention period is 1s
// scrape interval is 250ms
// => metrics should at least be stored for less than 1s, thus we wait 2s to check if metric was cleared
it('adds another metric for a new session', () => {
returnTwoPips = false
returnOnePip3 = true
return waitAndExpectMetricStrings(2000, 'user_sessions_currently_active{user="pip"} 0')
returnOnePip3 = false
return waitAndExpectMetricStrings(500, 'user_sessions_currently_active{user="pip"} 2', 'user_sessions_currently_active{user="pipX"} 1')
})

it('add another metric for a new session', () => {
it('adds metrics for each session', () => {
returnOnePip3 = false
returnTwoPips = false
returnOnePip3 = true
return waitAndExpectMetricStrings(500, 'user_sessions_currently_active{user="pip"} 0', 'user_sessions_currently_active{user="pip3"} 1')
throwError = false
return waitAndExpectMetricStrings(500,
'each_session_currently_active{user="pip",ip="192.168.2.107",tty="pts/0"} 1',
'each_session_currently_active{user="pip",ip="192.168.2.107",tty="pts/1"} 1',
'each_session_currently_active{user="pipX",ip="44.33.22.1",tty="pts/2"} 1')
})
})

0 comments on commit e51cda1

Please sign in to comment.