Skip to content

Commit abd1a4f

Browse files
committedNov 22, 2020
Initial upload
1 parent 34f8309 commit abd1a4f

24 files changed

+1237
-0
lines changed
 

‎.gitignore

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
npm-debug.log
3+
.env
4+
.idea
5+
.npmrc
6+
yarn.*
7+
.vscode
8+
yarn-error.log
9+
dist

‎.npmignore

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
npm-debug.log
3+
.env
4+
.idea
5+
.npmrc
6+
yarn.*
7+
.vscode
8+
yarn-error.log
9+
example

‎.prettierignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package.json
2+
node_modules
3+
dist
4+
coverage

‎.prettierrc.yml

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
trailingComma: es5
2+
singleQuote: true
3+
printWidth: 120
4+
tabWidth: 4
5+
bracketSpacing: false

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog

‎LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2020 Soluto by Asurion
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md

+107
Original file line numberDiff line numberDiff line change
@@ -1 +1,108 @@
11
# monitored
2+
3+
A utility for monitoring services
4+
5+
# API
6+
7+
### setGlobalInstance
8+
9+
In order to wire this pacakge you need to pass `Options` object.
10+
11+
`serviceName` - represents the name of the service you are monitoring (mandatory)
12+
13+
`logger` - writes success and error logs with the passed in logger (optional)
14+
15+
`statsd` - writes metrics to statsd server (optional)
16+
17+
```
18+
setGlobalInstance(new Monitor({
19+
serviceName: 'monitored-example',
20+
logging: {
21+
logger,
22+
logErrorsAsWarnings?: true,
23+
defaultParseError?: e => new Error(e.message + " defaultParseError")
24+
25+
},
26+
statsd: {
27+
apiKey: 'STATSD_API_KEY',
28+
root: 'testing',
29+
host: 'STATSD_HOST',
30+
},
31+
shouldMonitorExecutionStart?: boolean; //when true will log execution start and will increment a metrics. defaults to true
32+
}));
33+
```
34+
35+
### monitored
36+
37+
Wrapper function that write success/error logs and statsd metrics (gague, increment, timing) after execution.
38+
It supports both asynchronous and synchronous functions:
39+
40+
```
41+
42+
const asyncFunc1 = monitored('foo1', () => {
43+
console.log('bar1');
44+
return Promise.resolve();
45+
});
46+
47+
const asyncFunc2 = monitored('foo2', async () => {
48+
await Promise.resolve();
49+
console.log('bar1');
50+
});
51+
52+
const syncFunc = monitored('foo3', () => {
53+
console.log('bar2');
54+
});
55+
```
56+
57+
You can pass `options` argument to `monitored`:
58+
59+
```
60+
type MonitoredOptions = {
61+
context?; //add more inforamtion to the logs
62+
logResult?: boolean; //should write log of the method start and success
63+
parseResult?: (e: any) => any; //custom parser for result (in case it is logged)
64+
parseError?: (e: any) => any; //custom parser for different types of errors
65+
level?: 'info' | 'debug'; //which log level to write (debug is the default)
66+
logAsError?: boolean; //enables to write error log in case the global `logErrorsAsWarnings` is on
67+
logErrorAsInfo?: boolean //enables to write the errpr as info log
68+
shouldMonitorError: e => boolean //determines if error should be monitored and logged, defaults to true
69+
shouldMonitorSuccess: (r: T) => boolean //determines if success result should be monitored and logged, defaults to true
70+
71+
};
72+
```
73+
74+
```
75+
const foo3 = monitored('foo3', () => {
76+
console.log('bar3');
77+
}, {context: {id: 'some context'}});
78+
```
79+
80+
Also you can log the function result by setting `logResult` to `true`:
81+
82+
```
83+
const foo4 = monitored('foo4', () => {
84+
console.log('bar4');
85+
}, {context: {id: 'some context'}, logResult: true});
86+
```
87+
88+
Full usage documentation is details in `Testing` section
89+
90+
### getStatsdClient
91+
92+
Returns the StatsD client directly, in order to write custom metrics
93+
94+
### flush
95+
96+
Wait for all current metric to be sent to server.
97+
You should use it in the end of lambda execution to make sure all metrics are sent.
98+
99+
```
100+
await monitor.flush(timeout: number = 2000)
101+
```
102+
103+
104+
## Testing
105+
106+
1. Create `.env` file with `STATSD_API_KEY` and `STATSD_HOST` values
107+
2. Run `yarn example`
108+
3. Verify manually that console logs and metrics in the statsd server are valid

‎__tests__/AsyncStatsD.spec.ts

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {MonitorOptions} from '../src';
2+
import {AsyncStatsD} from '../src/AsyncStatsD';
3+
import {mocked} from 'ts-jest/utils';
4+
import {StatsCb} from 'hot-shots';
5+
import { Logger } from '../src/Logger';
6+
import {consoleLogger} from '../src/loggers';
7+
8+
jest.mock('hot-shots');
9+
10+
const statsdOptions: MonitorOptions['statsd'] = {
11+
apiKey: 'key',
12+
host: 'host',
13+
root: 'root',
14+
};
15+
16+
let client: AsyncStatsD;
17+
18+
describe('AsyncStatsD', () => {
19+
beforeEach(() => {
20+
jest.resetAllMocks();
21+
client = new AsyncStatsD(new Logger(consoleLogger), statsdOptions);
22+
});
23+
24+
describe('increment', () => {
25+
test('success', async () => {
26+
mockStatsdCallback(client.statsd.increment, undefined);
27+
await expect(client.increment('test', 3)).resolves.toEqual(undefined);
28+
29+
expect(mocked(client.statsd.increment)).toHaveBeenCalledWith('test', 3, expect.any(Function));
30+
});
31+
32+
test('success - use default value', async () => {
33+
mockStatsdCallback(client.statsd.increment, undefined);
34+
await expect(client.increment('test')).resolves.toEqual(undefined);
35+
36+
expect(mocked(client.statsd.increment)).toHaveBeenCalledWith('test', 1, expect.any(Function));
37+
});
38+
39+
test('error', async () => {
40+
mockStatsdCallback(client.statsd.increment, {error: 'err'});
41+
42+
await expect(client.increment('test', 3)).resolves.toEqual(undefined);
43+
44+
expect(mocked(client.statsd.increment)).toHaveBeenCalledWith('test', 3, expect.any(Function));
45+
});
46+
});
47+
48+
describe('timing', () => {
49+
test('success', async () => {
50+
mockStatsdCallback(client.statsd.timing, undefined);
51+
await expect(client.timing('test', 2000)).resolves.toEqual(undefined);
52+
53+
expect(mocked(client.statsd.timing)).toHaveBeenCalledWith('test', 2000, expect.any(Function));
54+
});
55+
56+
test('error', async () => {
57+
mockStatsdCallback(client.statsd.timing, {error: 'err'});
58+
59+
await expect(client.timing('test', 2000)).resolves.toEqual(undefined);
60+
61+
expect(mocked(client.statsd.timing)).toHaveBeenCalledWith('test', 2000, expect.any(Function));
62+
});
63+
});
64+
65+
describe('gauge', () => {
66+
test('success', async () => {
67+
mockStatsdCallback(client.statsd.gauge, undefined);
68+
await expect(client.gauge('test', 1)).resolves.toEqual(undefined);
69+
70+
expect(mocked(client.statsd.gauge)).toHaveBeenCalledWith('test', 1, expect.any(Function));
71+
});
72+
73+
test('error', async () => {
74+
mockStatsdCallback(client.statsd.gauge, {error: 'err'});
75+
76+
await expect(client.gauge('test', 1)).resolves.toEqual(undefined);
77+
78+
expect(mocked(client.statsd.gauge)).toHaveBeenCalledWith('test', 1, expect.any(Function));
79+
});
80+
});
81+
82+
describe('flush', () => {
83+
test('success', async () => {
84+
mockStatsdCallback(client.statsd.increment);
85+
client.increment('test', 3);
86+
client.increment('test', 3);
87+
88+
mockStatsdCallback(client.statsd.timing, {delay: 1000});
89+
client.timing('asjkdsajk', 1000);
90+
91+
await expect(client.flush()).resolves.toEqual(true);
92+
});
93+
94+
test('timeout', async () => {
95+
mockStatsdCallback(client.statsd.increment);
96+
client.increment('test', 3);
97+
client.increment('test', 3);
98+
99+
mockStatsdCallback(client.statsd.timing, {delay: 1500});
100+
client.timing('asjkdsajk', 1000);
101+
102+
await expect(client.flush(1000)).resolves.toEqual(false);
103+
});
104+
105+
test('success - some promises return errors', async () => {
106+
try {
107+
mockStatsdCallback(client.statsd.increment);
108+
client.increment('test', 3);
109+
client.increment('test', 3);
110+
111+
mockStatsdCallback(client.statsd.timing, {delay: 1000, error: 'errrrrrr'});
112+
client.timing('asjkdsajk', 1000);
113+
114+
mockStatsdCallback(client.statsd.increment, {delay: 1000, error: 'err2'});
115+
client.increment('test', 3);
116+
client.increment('test', 3);
117+
118+
await expect(client.flush(2000)).resolves.toEqual(true);
119+
} catch (err) {}
120+
});
121+
});
122+
});
123+
124+
interface MockStatsdCallbackOptions {
125+
delay?: number;
126+
error?: any;
127+
}
128+
129+
function mockStatsdCallback(func: (_a: string, _b: number, cb: StatsCb) => void, opts: MockStatsdCallbackOptions = {}) {
130+
const {delay = 50, error = undefined} = opts;
131+
132+
mocked(func).mockImplementation((_a, _b, cb) => {
133+
setTimeout(() => cb(error, undefined), delay);
134+
});
135+
}

‎__tests__/__mocks__/AsyncStatsD.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class AsyncStatsD {
2+
constructor() {}
3+
4+
increment = jest.fn();
5+
gauge = jest.fn();
6+
timing = jest.fn();
7+
close = jest.fn();
8+
flush = jest.fn();
9+
}

‎__tests__/monitor.spec.ts

+309
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import {mocked} from 'ts-jest/utils';
2+
import Monitor from '../src/Monitor';
3+
import {consoleLogger} from '../src/loggers';
4+
import {MonitorOptions} from '../src';
5+
import {
6+
assertIncrementWasCalled,
7+
assertIncrementWasNotCalled,
8+
assertTimingWasCalled,
9+
assertGaugeWasCalled,
10+
} from './utils';
11+
12+
jest.mock('../AsyncStatsD');
13+
jest.mock('../loggers', () => ({
14+
consoleLogger: {
15+
info: jest.fn(),
16+
debug: jest.fn(),
17+
warn: jest.fn(),
18+
error: jest.fn(),
19+
},
20+
emptyLogger: {
21+
info: jest.fn(),
22+
debug: jest.fn(),
23+
warn: jest.fn(),
24+
error: jest.fn(),
25+
},
26+
}));
27+
jest.mock('../parseError', () => ({
28+
default: err => err,
29+
}));
30+
31+
const statsdOptions: MonitorOptions['statsd'] = {
32+
apiKey: 'key',
33+
host: 'host',
34+
root: 'root',
35+
};
36+
37+
const defaultMonitorOptions: MonitorOptions = {
38+
serviceName: 'test-service',
39+
statsd: statsdOptions,
40+
};
41+
42+
describe('Monitor', () => {
43+
beforeEach(() => {
44+
jest.resetAllMocks();
45+
});
46+
47+
describe('monitored', () => {
48+
describe('validate result', () => {
49+
test('sync function', async () => {
50+
const mockReturn = 10;
51+
const mockFunc = jest.fn().mockReturnValue(mockReturn);
52+
const monitor = new Monitor({...defaultMonitorOptions});
53+
54+
const res = monitor.monitored('test', mockFunc);
55+
56+
expect(mockFunc).toBeCalledTimes(1);
57+
expect(res).toEqual(mockReturn);
58+
});
59+
60+
test('async function', async () => {
61+
const mockReturn = 10;
62+
const mockFunc = jest.fn().mockResolvedValue(mockReturn);
63+
const monitor = new Monitor({...defaultMonitorOptions});
64+
65+
const res = await monitor.monitored('test', mockFunc);
66+
67+
expect(mockFunc).toBeCalledTimes(1);
68+
expect(res).toEqual(mockReturn);
69+
});
70+
71+
test('sync function throws', async () => {
72+
const mockError = new Error('error');
73+
const mockFunc = jest.fn(() => {
74+
throw mockError;
75+
});
76+
const monitor = new Monitor({...defaultMonitorOptions});
77+
78+
try {
79+
monitor.monitored('test', mockFunc);
80+
81+
fail('should throw error');
82+
} catch (err) {
83+
expect(err).toEqual(mockError);
84+
}
85+
86+
expect(mockFunc).toBeCalledTimes(1);
87+
});
88+
89+
test('async function throws', async () => {
90+
const mockError = new Error('error');
91+
const mockFunc = jest.fn().mockRejectedValue(mockError);
92+
const monitor = new Monitor({...defaultMonitorOptions});
93+
94+
try {
95+
await monitor.monitored('test', mockFunc);
96+
97+
fail('should throw error');
98+
} catch (err) {
99+
expect(err).toEqual(mockError);
100+
}
101+
102+
expect(mockFunc).toBeCalledTimes(1);
103+
});
104+
});
105+
106+
describe('shouldMonitorExecutionStart', () => {
107+
test('true', async () => {
108+
const mockReturn = 10;
109+
const mockFunc = jest.fn().mockReturnValue(mockReturn);
110+
const monitor = new Monitor({...defaultMonitorOptions, shouldMonitorExecutionStart: true});
111+
112+
const res = monitor.monitored('test', mockFunc);
113+
114+
expect(mockFunc).toBeCalledTimes(1);
115+
expect(res).toEqual(mockReturn);
116+
assertIncrementWasCalled(monitor, 'test.start');
117+
expect(mocked(consoleLogger.debug)).toHaveBeenCalledWith('test.start', {extra: undefined});
118+
});
119+
120+
test('false', async () => {
121+
const mockReturn = 10;
122+
const mockFunc = jest.fn().mockReturnValue(mockReturn);
123+
const monitor = new Monitor({...defaultMonitorOptions, shouldMonitorExecutionStart: false});
124+
125+
const res = monitor.monitored('test', mockFunc);
126+
127+
expect(mockFunc).toBeCalledTimes(1);
128+
expect(res).toEqual(mockReturn);
129+
assertIncrementWasNotCalled(monitor, 'test.start');
130+
expect(mocked(consoleLogger.debug)).not.toHaveBeenCalledWith('test.start', expect.anything());
131+
});
132+
});
133+
134+
describe('result', () => {
135+
const mockReturn = 10;
136+
137+
[false, true].forEach(isAsync => {
138+
const mockFunc: any = isAsync
139+
? () =>
140+
new Promise<number>(resolve => {
141+
resolve(mockReturn);
142+
})
143+
: () => mockReturn;
144+
145+
describe(`${isAsync ? 'async' : 'sync'} function`, () => {
146+
test('default options', async () => {
147+
const monitor = new Monitor({...defaultMonitorOptions});
148+
149+
const res = monitor.monitored('test', mockFunc);
150+
151+
if (isAsync) {
152+
await res;
153+
}
154+
155+
assertIncrementWasCalled(monitor, 'test.success');
156+
assertGaugeWasCalled(monitor, 'test.ExecutionTime');
157+
assertTimingWasCalled(monitor, 'test.ExecutionTime');
158+
159+
expect(mocked(consoleLogger.debug)).toHaveBeenCalledWith('test.success', {
160+
extra: {
161+
executionTime: expect.any(Number),
162+
executionResult: 'NOT_LOGGED',
163+
},
164+
});
165+
});
166+
167+
test('send context', async () => {
168+
const context = {a: 'a', b: 'bbbbb', c: true};
169+
const monitor = new Monitor({...defaultMonitorOptions});
170+
171+
const res = monitor.monitored('test', mockFunc, {context});
172+
173+
if (isAsync) {
174+
await res;
175+
}
176+
177+
expect(mocked(consoleLogger.debug)).toHaveBeenCalledWith('test.success', {
178+
extra: {
179+
...context,
180+
executionTime: expect.any(Number),
181+
executionResult: 'NOT_LOGGED',
182+
},
183+
});
184+
});
185+
186+
test('logResult: true', async () => {
187+
const monitor = new Monitor({...defaultMonitorOptions, shouldMonitorExecutionStart: false});
188+
189+
const res = monitor.monitored('test', mockFunc, {logResult: true});
190+
191+
if (isAsync) {
192+
await res;
193+
}
194+
195+
expect(mocked(consoleLogger.debug)).toHaveBeenCalledWith('test.success', {
196+
extra: {
197+
executionTime: expect.any(Number),
198+
executionResult: mockReturn,
199+
},
200+
});
201+
});
202+
203+
test('logResult: true with parseResult', async () => {
204+
const monitor = new Monitor({...defaultMonitorOptions});
205+
const mockParsedReturn = 50;
206+
const parseResult = jest.fn().mockReturnValue(mockParsedReturn);
207+
208+
let res = monitor.monitored('test', mockFunc, {logResult: true, parseResult});
209+
210+
if (isAsync) {
211+
res = await res;
212+
}
213+
214+
expect(res).toEqual(mockReturn);
215+
216+
expect(mocked(consoleLogger.debug)).toHaveBeenCalledWith('test.success', {
217+
extra: {
218+
executionTime: expect.any(Number),
219+
executionResult: mockParsedReturn,
220+
},
221+
});
222+
});
223+
});
224+
});
225+
});
226+
227+
describe('error', () => {
228+
const mockError = new Error('error');
229+
230+
[false, true].forEach(isAsync => {
231+
const mockFunc: any = isAsync
232+
? () =>
233+
new Promise<number>((_, reject) => {
234+
reject(mockError);
235+
})
236+
: () => { throw mockError };
237+
238+
describe(`${isAsync ? 'async' : 'sync'} function`, () => {
239+
test('default options', async () => {
240+
const monitor = new Monitor({...defaultMonitorOptions});
241+
242+
try {
243+
const res = monitor.monitored('test', mockFunc);
244+
245+
if (isAsync) {
246+
await res;
247+
}
248+
249+
fail('Should not success');
250+
} catch (err) {
251+
expect(err).toEqual(mockError);
252+
253+
assertIncrementWasCalled(monitor, 'test.error');
254+
255+
expect(mocked(consoleLogger.error)).toHaveBeenCalledWith('test.error', {
256+
err: mockError,
257+
extra: undefined
258+
});
259+
}
260+
});
261+
262+
test('send context', async () => {
263+
const context = {a: 'a', b: 'bbbbb', c: true};
264+
const monitor = new Monitor({...defaultMonitorOptions});
265+
266+
try {
267+
const res = monitor.monitored('test', mockFunc, {context});
268+
269+
if (isAsync) {
270+
await res;
271+
}
272+
273+
fail('Should not success');
274+
} catch (err) {
275+
expect(err).toEqual(mockError);
276+
277+
expect(mocked(consoleLogger.error)).toHaveBeenCalledWith('test.error', {
278+
err: mockError,
279+
extra: context
280+
});
281+
}
282+
});
283+
284+
test('with parseError', async () => {
285+
const monitor = new Monitor({...defaultMonitorOptions});
286+
const mockParsedError = 'blabla';
287+
288+
try {
289+
const res = monitor.monitored('test', mockFunc, {parseError: () => mockParsedError});
290+
291+
if (isAsync) {
292+
await res;
293+
}
294+
295+
fail('Should not success');
296+
} catch (err) {
297+
expect(err).toEqual(mockError);
298+
299+
expect(mocked(consoleLogger.error)).toHaveBeenCalledWith('test.error', {
300+
err: mockParsedError,
301+
extra: undefined
302+
});
303+
}
304+
});
305+
});
306+
});
307+
});
308+
});
309+
});

‎__tests__/utils.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {mocked} from 'ts-jest/utils';
2+
import Monitor from '../src/Monitor';
3+
4+
export function assertIncrementWasCalled(monitor: Monitor, metricName: string) {
5+
expect(mocked(monitor.getStatsdClient()!.increment)).toHaveBeenCalledWith(metricName);
6+
}
7+
8+
export function assertIncrementWasNotCalled(monitor: Monitor, metricName: string) {
9+
expect(mocked(monitor.getStatsdClient()!.increment)).not.toHaveBeenCalledWith(metricName);
10+
}
11+
12+
export function assertTimingWasCalled(monitor: Monitor, metricName: string) {
13+
expect(mocked(monitor.getStatsdClient()!.timing)).toHaveBeenCalledWith(metricName, expect.any(Number));
14+
}
15+
16+
export function assertTimingWasNotCalled(monitor: Monitor, metricName: string) {
17+
expect(mocked(monitor.getStatsdClient()!.timing)).not.toHaveBeenCalledWith(metricName, expect.any(Number));
18+
}
19+
20+
export function assertGaugeWasCalled(monitor: Monitor, metricName: string) {
21+
expect(mocked(monitor.getStatsdClient()!.gauge)).toHaveBeenCalledWith(metricName, expect.any(Number));
22+
}
23+
24+
export function assertGaugeWasNotCalled(monitor: Monitor, metricName: string) {
25+
expect(mocked(monitor.getStatsdClient()!.gauge)).not.toHaveBeenCalledWith(metricName, expect.any(Number));
26+
}

‎example/run.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import 'dotenv/config';
2+
3+
import {setGlobalInstance, monitored, Monitor} from '../src';
4+
5+
setGlobalInstance(new Monitor({
6+
serviceName: 'monitored-example',
7+
logging: {
8+
logger: {warn: console.warn, info: console.info, debug: console.debug, error: console.error},
9+
logErrorsAsWarnings: true,
10+
defaultParseError: _e => 'defaultParseError',
11+
},
12+
}));
13+
14+
monitored('foo1', () => 'bar1', {context: {id: 1}, logResult: true});
15+
16+
try {
17+
monitored(
18+
'foo2',
19+
() => {
20+
throw 'something';
21+
},
22+
{logAsError: true, parseError: () => 'wrong!'}
23+
);
24+
} catch (e) {
25+
console.log('caught foo2', e);
26+
}
27+
28+
const someResolvingPromise = (x: string) => Promise.resolve(x);
29+
monitored('foo3', () => someResolvingPromise('some string')).then(s =>
30+
console.log(`Promise resolved with return value - ${s}`)
31+
);
32+
33+
const someRejectingPromise = (x: string) => Promise.reject(x);
34+
monitored('foo4', () => someRejectingPromise('some reject string')).catch(e => console.log('caught promise', e));
35+
36+
monitored('foo5', () => 'bar5', {context: {id: 5}, logResult: false});
37+
38+
monitored('foo6', () => 'bar6', {context: {id: 6}, logResult: true});
39+
40+
monitored('foo7', () => 'bar5', {level: 'debug'});
41+
42+
monitored('foo8', () => 'bar8', {level: 'info'});
43+
44+
monitored('foo9', () => 'bar9');
45+
46+
try {
47+
monitored('foo10', () => {
48+
throw new Error('error!');
49+
});
50+
} catch {}
51+
52+
monitored('foo11', () => 'bar11');
53+
54+
try {
55+
monitored(
56+
'foo12',
57+
() => {
58+
throw new Error('error!');
59+
},
60+
{logAsError: true, shouldMonitorError: () => false}
61+
);
62+
} catch {}
63+
64+
monitored(
65+
'foo13',
66+
async () => {
67+
throw new Error('error!');
68+
},
69+
{logAsError: true, shouldMonitorError: () => true}
70+
).catch(() => {});
71+
72+
monitored('foo14', () => 'bar14', {shouldMonitorSuccess: () => false});
73+
monitored('foo15', () => 'bar15', {shouldMonitorSuccess: () => true});
74+
75+
try {
76+
monitored(
77+
'foo16',
78+
() => {
79+
throw new Error('error!');
80+
},
81+
{logErrorAsInfo: true}
82+
);
83+
} catch {}
84+
85+
monitored(
86+
'foo17',
87+
async () => {
88+
throw new Error('error!');
89+
},
90+
{logErrorAsInfo: true}
91+
).catch(() => {});

‎jest.config.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = {
2+
rootDir: '.',
3+
moduleFileExtensions: ['ts', 'tsx', 'js'],
4+
transform: {
5+
'^.+\\.(ts|tsx)$': 'ts-jest',
6+
},
7+
globals: {
8+
'ts-jest': {
9+
tsConfig: 'tsconfig.json',
10+
},
11+
},
12+
testMatch: ['__tests__/**/*.spec.+(ts|tsx|js)'],
13+
};

‎package.json

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "monitored",
3+
"version": "0.0.1",
4+
"description": "monitored",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"scripts": {
8+
"format": "prettier --write '**/*.{ts,js,json}'",
9+
"precommit": "lint-staged",
10+
"build": "rm -rf dist && tsc -p tsconfig.json",
11+
"example": "ts-node example/run.ts",
12+
"prepublish": "yarn build && yarn test",
13+
"test": "jest --config jest.config.js --forceExit"
14+
},
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/Soluto/monitored.git"
18+
},
19+
"author": "Soluto",
20+
"license": "MIT",
21+
"bugs": {
22+
"url": "https://github.com/Soluto/monitored/issues"
23+
},
24+
"homepage": "https://github.com/Soluto/monitored#readme",
25+
"dependencies": {
26+
"hot-shots": "^7.4.0"
27+
},
28+
"devDependencies": {
29+
"@types/jest": "^25.1.0",
30+
"@types/node": "^12.0.0",
31+
"dotenv": "^5.0.1",
32+
"jest": "^25.1.0",
33+
"lint-staged": "^6.0.1",
34+
"prettier": "^1.8.2",
35+
"ts-jest": "^25.0.0",
36+
"ts-node": "^7.0.1",
37+
"typescript": "^3.7.5"
38+
},
39+
"lint-staged": {
40+
"*.{js,ts,tsx,css}": [
41+
"prettier --write",
42+
"git add"
43+
]
44+
}
45+
}

‎src/AsyncStatsD.ts

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {StatsD, ClientOptions} from 'hot-shots';
2+
import {promisify} from 'util';
3+
import {timeoutPromise} from './utils';
4+
import {Logger} from './Logger';
5+
6+
const noop = () => {};
7+
8+
export class AsyncStatsD {
9+
private client: StatsD;
10+
private logger: Logger;
11+
private promiseCount: number;
12+
private pendingPromises: Record<number, Promise<any>>;
13+
14+
private _increment: (name: string, value: number) => Promise<void>;
15+
private _gauge: (name: string, value: number) => Promise<void>;
16+
private _timing: (name: string, value: number) => Promise<void>;
17+
close: () => Promise<void>;
18+
19+
constructor(logger: Logger, options?: ClientOptions) {
20+
this.client = new StatsD({cacheDns: true, ...options});
21+
this.logger = logger;
22+
this.promiseCount = 0;
23+
this.pendingPromises = {};
24+
25+
this._increment = promisify(this.client.increment.bind(this.client));
26+
this._gauge = promisify(this.client.gauge.bind(this.client));
27+
this._timing = promisify(this.client.timing.bind(this.client));
28+
this.close = promisify(this.client.close.bind(this.client));
29+
}
30+
31+
get statsd() {
32+
return this.client;
33+
}
34+
35+
increment = async (name: string, value: number = 1) => {
36+
try {
37+
await this.wrapStatsdPromise(this._increment(name, value));
38+
} catch (err) {
39+
this.logger.error(`Failed to send increment: ${name}`, err);
40+
}
41+
};
42+
43+
gauge = async (name: string, value: number) => {
44+
try {
45+
await this.wrapStatsdPromise(this._gauge(name, value));
46+
} catch (err) {
47+
this.logger.error(`Failed to send gauge: ${name}`, err);
48+
}
49+
};
50+
51+
timing = async (name: string, value: number) => {
52+
try {
53+
await this.wrapStatsdPromise(this._timing(name, value));
54+
} catch (err) {
55+
this.logger.error(`Failed to send timing: ${name}`, err);
56+
}
57+
};
58+
59+
flush = async (timeout: number = 2000) => {
60+
const remainingPromises = Object.values(this.pendingPromises).map(p => p.catch(noop));
61+
62+
if (remainingPromises.length > 0) {
63+
try {
64+
await timeoutPromise(
65+
timeout,
66+
Promise.all(remainingPromises),
67+
'Timeout reached, stopped wait for pending log writes'
68+
);
69+
} catch (err) {
70+
this.logger.error('flush timeout', err);
71+
72+
return false;
73+
}
74+
}
75+
76+
return true;
77+
};
78+
79+
private wrapStatsdPromise = async <R = void>(prom: Promise<R>): Promise<R> => {
80+
const currentCount = this.incrementPromiseCount();
81+
82+
try {
83+
this.pendingPromises[currentCount] = prom;
84+
85+
return await this.pendingPromises[currentCount];
86+
} finally {
87+
delete this.pendingPromises[currentCount];
88+
}
89+
};
90+
91+
private incrementPromiseCount = () => {
92+
this.promiseCount = (this.promiseCount + 1) % Number.MAX_SAFE_INTEGER;
93+
94+
return this.promiseCount;
95+
};
96+
}

‎src/Logger.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
interface LoggerConfig {
2+
logErrorsAsWarnings: boolean;
3+
}
4+
5+
export class Logger {
6+
private logger: any;
7+
private config: LoggerConfig;
8+
9+
constructor(logger: any, config?: Partial<LoggerConfig>) {
10+
this.logger = logger;
11+
this.config = {logErrorsAsWarnings: false, ...config};
12+
}
13+
14+
debug = (message, {extra}) => {
15+
try {
16+
this.logger.debug(message, {extra});
17+
} catch (_) {}
18+
};
19+
20+
info = (message, {extra}) => {
21+
try {
22+
this.logger.info(message, {extra});
23+
} catch (_) {}
24+
};
25+
26+
error = (message: string, err?: Error, extra?: any, logAsError?: boolean, logErrorAsInfo?: boolean) => {
27+
try {
28+
if (logErrorAsInfo) {
29+
this.logger.info(message, {err, extra});
30+
return;
31+
}
32+
if (logAsError) {
33+
this.logger.error(message, {err, extra});
34+
return;
35+
}
36+
if (this.config.logErrorsAsWarnings) {
37+
this.logger.warn(message, {err, extra});
38+
return;
39+
}
40+
this.logger.error(message, {err, extra});
41+
} catch (_) {}
42+
};
43+
}

‎src/Monitor.ts

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {safe} from './utils';
2+
import {MonitorOptions, MonitoredOptions, Unpromisify} from './types';
3+
import {emptyLogger, consoleLogger} from './loggers';
4+
import parseError from './parseError';
5+
import {AsyncStatsD} from './AsyncStatsD';
6+
import {Logger} from './Logger';
7+
8+
interface Config {
9+
serviceName: string;
10+
shouldMonitorExecutionStart: boolean;
11+
disableSuccessLogs: boolean;
12+
}
13+
14+
class Monitor {
15+
private statsdClient: AsyncStatsD | undefined;
16+
private config: Config;
17+
private logger: Logger;
18+
private defaultParseError: (e: any) => any;
19+
20+
constructor(options: MonitorOptions) {
21+
this.config = {
22+
serviceName: options.serviceName ? options.serviceName + '.' : '',
23+
shouldMonitorExecutionStart: options.shouldMonitorExecutionStart ?? true,
24+
disableSuccessLogs: options.logging?.disableSuccessLogs ?? false,
25+
};
26+
27+
this.logger = new Logger(options.mock ? emptyLogger : options.logging?.logger ?? consoleLogger, {
28+
logErrorsAsWarnings: options.logging?.logErrorsAsWarnings,
29+
});
30+
this.defaultParseError = options.logging?.defaultParseError ?? parseError;
31+
32+
if (options.statsd) {
33+
const {apiKey, root, ...restStatsdOptions} = options.statsd;
34+
const prefixesArray = [apiKey, root];
35+
if (options.serviceName) {
36+
prefixesArray.push(options.serviceName);
37+
}
38+
const prefix = prefixesArray.join('.') + '.';
39+
40+
this.statsdClient = new AsyncStatsD(this.logger, {
41+
port: 8125,
42+
prefix,
43+
mock: options.mock,
44+
...restStatsdOptions,
45+
});
46+
}
47+
}
48+
49+
monitored = <T>(name: string, callable: () => T, options: MonitoredOptions<T> = {}) => {
50+
const {level = 'debug', context} = options;
51+
const startTime = Date.now();
52+
53+
if (this.config.shouldMonitorExecutionStart) {
54+
this.increment(`${name}.start`);
55+
this.monitoredLogger(level, `${name}.start`, {extra: context});
56+
}
57+
58+
try {
59+
const result = callable();
60+
if (result && result instanceof Promise) {
61+
return <T>(
62+
(<any>(
63+
result
64+
.then((promiseResult: Unpromisify<T>) =>
65+
this.onResult(promiseResult, name, startTime, options)
66+
)
67+
.catch(err => this.onErrorAsync(err, name, options))
68+
))
69+
);
70+
}
71+
return this.onResult(result as Unpromisify<T>, name, startTime, options);
72+
} catch (err) {
73+
return this.onErrorSync(err, name, options);
74+
}
75+
};
76+
77+
getStatsdClient = () => this.statsdClient;
78+
79+
increment: AsyncStatsD['increment'] = async (...args) => this.statsdClient?.increment(...args);
80+
81+
gauge: AsyncStatsD['gauge'] = async (...args) => this.statsdClient?.gauge(...args);
82+
83+
timing: AsyncStatsD['timing'] = async (...args) => this.statsdClient?.timing(...args);
84+
85+
flush: AsyncStatsD['flush'] = async (...args) => this.statsdClient?.flush(...args) ?? true;
86+
87+
monitoredLogger = (level: MonitoredOptions<any>['level'], message, {extra}) => {
88+
const log = level === 'info' ? this.logger.info : this.logger.debug;
89+
log(message, {extra});
90+
};
91+
92+
private onResult = <T>(
93+
result: Unpromisify<T>,
94+
name: string,
95+
startTime: number,
96+
{shouldMonitorSuccess, context, parseResult, logResult, level}: MonitoredOptions<T>
97+
): Unpromisify<T> => {
98+
const executionTime = Date.now() - startTime;
99+
100+
if (shouldMonitorSuccess?.(result) ?? true) {
101+
this.increment(`${name}.success`);
102+
this.gauge(`${name}.ExecutionTime`, executionTime);
103+
this.timing(`${name}.ExecutionTime`, executionTime);
104+
}
105+
106+
if (!this.config.disableSuccessLogs) {
107+
this.monitoredLogger(level, `${name}.success`, {
108+
extra: {
109+
...context,
110+
executionTime,
111+
executionResult: logResult ? safe(parseResult)(result) : 'NOT_LOGGED',
112+
},
113+
});
114+
}
115+
116+
return result;
117+
};
118+
119+
private onErrorAsync = async (
120+
err,
121+
name: string,
122+
{shouldMonitorError, context, logAsError, logErrorAsInfo, parseError}: MonitoredOptions<never>
123+
) => {
124+
if (shouldMonitorError && !shouldMonitorError(err)) throw err;
125+
this.increment(`${name}.error`);
126+
this.logger.error(
127+
`${name}.error`,
128+
await safe(parseError || this.defaultParseError)(err),
129+
context,
130+
logAsError,
131+
logErrorAsInfo
132+
);
133+
throw err;
134+
};
135+
136+
private onErrorSync = (
137+
err,
138+
name: string,
139+
{shouldMonitorError, context, logAsError, logErrorAsInfo, parseError}: MonitoredOptions<never>
140+
) => {
141+
if (shouldMonitorError && shouldMonitorError(err)) throw err;
142+
this.increment(`${name}.error`);
143+
this.logger.error(
144+
`${name}.error`,
145+
safe(parseError || this.defaultParseError)(err),
146+
context,
147+
logAsError,
148+
logErrorAsInfo
149+
);
150+
throw err;
151+
};
152+
}
153+
154+
export default Monitor;

‎src/globalInstance.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Monitor from './Monitor';
2+
3+
let instance: Monitor | undefined;
4+
5+
export function setGlobalInstance(monitor: Monitor) {
6+
instance = monitor;
7+
}
8+
9+
export function getGlobalInstance(): Monitor {
10+
if (!instance) {
11+
instance = new Monitor({
12+
serviceName: '',
13+
});
14+
}
15+
16+
return instance;
17+
}
18+
19+
export const monitored: Monitor['monitored'] = (...args) => getGlobalInstance().monitored(...args);
20+
export const getStatsdClient: Monitor['getStatsdClient'] = (...args) => getGlobalInstance().getStatsdClient(...args);
21+
export const increment: Monitor['increment'] = (...args) => getGlobalInstance().increment(...args);
22+
export const gauge: Monitor['gauge'] = (...args) => getGlobalInstance().gauge(...args);
23+
export const timing: Monitor['timing'] = (...args) => getGlobalInstance().timing(...args);

‎src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {default as Monitor} from './Monitor';
2+
export * from './globalInstance';
3+
export {default as parseError} from './parseError';
4+
export * from './types';

‎src/loggers.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
type LogOptions = {
2+
err?: Error;
3+
extra?: any;
4+
};
5+
6+
export const consoleLogger = {
7+
info: (message, logOptions: LogOptions = {}) => console.log(message, logOptions.extra),
8+
debug: (message, logOptions: LogOptions = {}) => console.log(message, logOptions.extra),
9+
warn: (message, logOptions: LogOptions = {}) => console.log(message, logOptions.err, logOptions.extra),
10+
error: (message, logOptions: LogOptions = {}) => console.log(message, logOptions.err, logOptions.extra),
11+
};
12+
13+
export const emptyLogger = {
14+
info: () => {},
15+
debug: () => {},
16+
warn: () => {},
17+
error: () => {},
18+
};

‎src/parseError.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default async function(e: any) {
2+
try {
3+
// e.text function should be fetch error
4+
if (e.text && typeof e.text === 'function') {
5+
try {
6+
const text = await e.clone().text();
7+
return JSON.parse(text);
8+
} catch {
9+
return {status: e.status, statusText: e.statusText, url: e.url};
10+
}
11+
}
12+
// Axios error has buffer properties, toJSON will create a nice object
13+
if (e.isAxiosError) {
14+
return e.toJSON();
15+
}
16+
if (e.response) {
17+
return {
18+
status: e.response.status,
19+
statusText: e.response.statusText,
20+
data: e.response.data,
21+
};
22+
}
23+
if (e.body) {
24+
try {
25+
return JSON.parse(e.body);
26+
} catch {
27+
return e.body;
28+
}
29+
}
30+
return e;
31+
} catch {
32+
return e;
33+
}
34+
};

‎src/types.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {ClientOptions as StatsdClientOptions} from 'hot-shots';
2+
3+
interface StatsdOptions extends Omit<StatsdClientOptions, 'prefix'> {
4+
apiKey: string;
5+
root: string;
6+
host: string;
7+
}
8+
9+
export type MonitorOptions = {
10+
serviceName: string;
11+
statsd?: StatsdOptions;
12+
logging?: {
13+
logger: any;
14+
logErrorsAsWarnings?: boolean;
15+
disableSuccessLogs?: boolean;
16+
defaultParseError?: (e: any) => any;
17+
};
18+
shouldMonitorExecutionStart?: boolean;
19+
mock?: boolean;
20+
};
21+
22+
export type Unpromisify<T> = T extends PromiseLike<infer U> ? U : T
23+
24+
export type MonitoredOptions<T> = {
25+
context?: any;
26+
logResult?: boolean;
27+
parseResult?: (r: Unpromisify<T>) => any;
28+
parseError?: (e: any) => any;
29+
level?: 'info' | 'debug';
30+
logAsError?: boolean;
31+
logErrorAsInfo?: boolean,
32+
shouldMonitorError?: (e: any) => boolean;
33+
shouldMonitorSuccess?: (r: Unpromisify<T>) => boolean;
34+
};

‎src/utils.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export const safe = fn => r => {
2+
try {
3+
if (fn) {
4+
return fn(r);
5+
}
6+
return r;
7+
} catch {
8+
return r;
9+
}
10+
};
11+
12+
export const timeoutPromise = function(ms: number, promise: Promise<any>, timeoutError?: string) {
13+
// Create a promise that rejects in <ms> milliseconds
14+
let timeout = new Promise((_, reject) => {
15+
let id = setTimeout(() => {
16+
clearTimeout(id);
17+
reject(timeoutError || 'Timed out in ' + ms + 'ms.');
18+
}, ms);
19+
});
20+
21+
// Returns a race between our timeout and the passed in promise
22+
return Promise.race([promise, timeout]);
23+
};

‎tsconfig.json

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2015",
4+
"module": "commonjs",
5+
"moduleResolution": "node",
6+
"declaration": true,
7+
"rootDir": "src",
8+
"outDir": "dist",
9+
"removeComments": true,
10+
"allowSyntheticDefaultImports": true,
11+
"strict": true,
12+
"suppressImplicitAnyIndexErrors": true,
13+
"sourceMap": false,
14+
"forceConsistentCasingInFileNames": true,
15+
"noImplicitReturns": true,
16+
"noUnusedLocals": true,
17+
"noUnusedParameters": true,
18+
"lib": ["dom", "es2017"],
19+
"skipLibCheck": true,
20+
"noImplicitAny": false
21+
},
22+
"include": ["src/**/*", "types/*.d.ts"],
23+
"exclude": ["__tests__", "dist"]
24+
}

0 commit comments

Comments
 (0)
Please sign in to comment.