Skip to content

Commit 3f85ca0

Browse files
committed
add cloudwatch metrics
1 parent d8516ef commit 3f85ca0

File tree

2 files changed

+367
-0
lines changed

2 files changed

+367
-0
lines changed

src/metrics.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import CloudWatch, { Dimension } from 'aws-sdk/clients/cloudwatch';
2+
3+
import { SessionProxy } from './proxy';
4+
import { Action, MetricTypes, StandardUnit } from './interface';
5+
6+
7+
const LOGGER = console;
8+
const METRIC_NAMESPACE_ROOT = 'AWS/CloudFormation';
9+
10+
export function formatDimensions(dimensions: Map<string, string>): Array<Dimension> {
11+
const formatted: Array<Dimension> = [];
12+
dimensions.forEach((value: string, key: string) => {
13+
formatted.push({
14+
Name: key,
15+
Value: value,
16+
})
17+
});
18+
return formatted;
19+
}
20+
21+
export class MetricPublisher {
22+
public client: CloudWatch;
23+
24+
constructor (session: SessionProxy, public namespace: string) {
25+
this.client = session.client('CloudWatch') as CloudWatch;
26+
}
27+
28+
publishMetric(
29+
metricName: MetricTypes,
30+
dimensions: Map<string, string>,
31+
unit: StandardUnit,
32+
value: number,
33+
timestamp: Date,
34+
): void {
35+
try {
36+
this.client.putMetricData({
37+
Namespace: this.namespace,
38+
MetricData: [{
39+
MetricName: metricName,
40+
Dimensions: formatDimensions(dimensions),
41+
Unit: unit,
42+
Timestamp: timestamp,
43+
Value: value,
44+
}],
45+
});
46+
} catch(err) {
47+
LOGGER.error(`An error occurred while publishing metrics: ${err.message}`);
48+
}
49+
}
50+
}
51+
52+
export class MetricsPublisherProxy {
53+
public namespace: string;
54+
private publishers: Array<MetricPublisher>;
55+
56+
constructor(public accountId: string, public resourceType: string) {
57+
this.namespace = MetricsPublisherProxy.makeNamespace(accountId, resourceType);
58+
this.resourceType = resourceType;
59+
this.publishers = [];
60+
}
61+
62+
static makeNamespace(accountId: string, resourceType: string): string {
63+
const suffix = resourceType.replace(/::/g, '/');
64+
return `${METRIC_NAMESPACE_ROOT}/${accountId}/${suffix}`;
65+
}
66+
67+
addMetricsPublisher(session?: SessionProxy): void {
68+
if (session) {
69+
this.publishers.push(new MetricPublisher(session, this.namespace));
70+
}
71+
}
72+
73+
publishExceptionMetric(timestamp: Date, action: Action, error: Error): void {
74+
const dimensions = new Map<string, string>();
75+
dimensions.set('DimensionKeyActionType', action);
76+
dimensions.set('DimensionKeyExceptionType', error.constructor.name);
77+
dimensions.set('DimensionKeyResourceType', this.resourceType);
78+
this.publishers.forEach((publisher: MetricPublisher) => {
79+
publisher.publishMetric(
80+
MetricTypes.HandlerException,
81+
dimensions,
82+
StandardUnit.Count,
83+
1.0,
84+
timestamp,
85+
);
86+
});
87+
}
88+
89+
publishInvocationMetric(timestamp: Date, action: Action): void {
90+
const dimensions = new Map<string, string>();
91+
dimensions.set('DimensionKeyActionType', action);
92+
dimensions.set('DimensionKeyResourceType', this.resourceType);
93+
this.publishers.forEach((publisher: MetricPublisher) => {
94+
publisher.publishMetric(
95+
MetricTypes.HandlerInvocationCount,
96+
dimensions,
97+
StandardUnit.Count,
98+
1.0,
99+
timestamp,
100+
);
101+
});
102+
}
103+
104+
publishDurationMetric(timestamp: Date, action: Action, milliseconds: number): void {
105+
const dimensions = new Map<string, string>();
106+
dimensions.set('DimensionKeyActionType', action);
107+
dimensions.set('DimensionKeyResourceType', this.resourceType);
108+
this.publishers.forEach((publisher: MetricPublisher) => {
109+
publisher.publishMetric(
110+
MetricTypes.HandlerInvocationDuration,
111+
dimensions,
112+
StandardUnit.Milliseconds,
113+
milliseconds,
114+
timestamp,
115+
);
116+
});
117+
}
118+
119+
publishLogDeliveryExceptionMetric(timestamp: Date, error: any): void {
120+
const dimensions = new Map<string, string>();
121+
dimensions.set('DimensionKeyActionType', 'ProviderLogDelivery');
122+
dimensions.set('DimensionKeyExceptionType', error.constructor.name);
123+
dimensions.set('DimensionKeyResourceType', this.resourceType);
124+
this.publishers.forEach((publisher: MetricPublisher) => {
125+
publisher.publishMetric(
126+
MetricTypes.HandlerException,
127+
dimensions,
128+
StandardUnit.Count,
129+
1.0,
130+
timestamp,
131+
);
132+
});
133+
}
134+
}

tests/lib/metrics.test.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import CloudWatch from 'aws-sdk/clients/cloudwatch';
2+
import awsUtil = require('aws-sdk/lib/util');
3+
4+
import { Action, MetricTypes, StandardUnit } from '../../src/interface';
5+
import { SessionProxy } from '../../src/proxy';
6+
import {
7+
MetricPublisher,
8+
MetricsPublisherProxy,
9+
formatDimensions,
10+
} from '../../src/metrics';
11+
12+
const mockResult = (output: any): jest.Mock => {
13+
return jest.fn().mockReturnValue({
14+
promise: jest.fn().mockResolvedValue(output)
15+
});
16+
};
17+
18+
const MOCK_DATE = new Date('2020-01-01T23:05:38.964Z');
19+
const ACCOUNT_ID = '123412341234';
20+
const RESOURCE_TYPE = 'Aa::Bb::Cc';
21+
const NAMESPACE = MetricsPublisherProxy.makeNamespace(
22+
ACCOUNT_ID, RESOURCE_TYPE
23+
);
24+
25+
jest.mock('aws-sdk/clients/cloudwatch');
26+
27+
describe('when getting metrics', () => {
28+
29+
let session: SessionProxy;
30+
let cloudwatch: jest.Mock;
31+
let putMetricData: jest.Mock;
32+
33+
beforeAll(() => {
34+
session = new SessionProxy({});
35+
putMetricData = mockResult({ ResponseMetadata: { RequestId: 'mock-request' }});
36+
cloudwatch = (CloudWatch as unknown) as jest.Mock;
37+
cloudwatch.mockImplementation(() => {
38+
const returnValue = {
39+
putMetricData,
40+
};
41+
return {
42+
...returnValue,
43+
makeRequest: (operation: string, params?: {[key: string]: any}) => {
44+
return returnValue[operation](params);
45+
}
46+
};
47+
});
48+
session['client'] = cloudwatch;
49+
});
50+
51+
afterEach(() => {
52+
jest.clearAllMocks();
53+
jest.restoreAllMocks();
54+
});
55+
56+
test('format dimensions', () => {
57+
const dimensions = new Map<string, string>();
58+
dimensions.set('MyDimensionKeyOne', 'valOne');
59+
dimensions.set('MyDimensionKeyTwo', 'valTwo');
60+
const result = formatDimensions(dimensions);
61+
expect(result).toMatchObject([
62+
{Name: 'MyDimensionKeyOne', Value: 'valOne'},
63+
{Name: 'MyDimensionKeyTwo', Value: 'valTwo'},
64+
]);
65+
});
66+
67+
test('put metric catches error', () => {
68+
const spyConsoleError: jest.SpyInstance = jest
69+
.spyOn(global.console, 'error').mockImplementation(() => {});
70+
putMetricData.mockImplementationOnce(() => {
71+
throw awsUtil.error(new Error(), {
72+
code: 'InternalServiceError',
73+
message: 'An error occurred (InternalServiceError) when '
74+
+ 'calling the PutMetricData operation: ',
75+
});
76+
});
77+
const publisher = new MetricPublisher(session, NAMESPACE);
78+
const dimensions = new Map<string, string>();
79+
dimensions.set('DimensionKeyActionType', Action.Create);
80+
dimensions.set('DimensionKeyResourceType', RESOURCE_TYPE);
81+
publisher.publishMetric(
82+
MetricTypes.HandlerInvocationCount,
83+
dimensions,
84+
StandardUnit.Count,
85+
1.0,
86+
MOCK_DATE,
87+
);
88+
expect(putMetricData).toHaveBeenCalledTimes(1);
89+
expect(putMetricData).toHaveBeenCalledWith({
90+
MetricData: [{
91+
Dimensions: [
92+
{
93+
Name: 'DimensionKeyActionType',
94+
Value: 'CREATE',
95+
},
96+
{
97+
Name: 'DimensionKeyResourceType',
98+
Value: 'Aa::Bb::Cc',
99+
},
100+
],
101+
MetricName: MetricTypes.HandlerInvocationCount,
102+
Timestamp: MOCK_DATE,
103+
Unit: StandardUnit.Count,
104+
Value: 1.0,
105+
}],
106+
Namespace: 'AWS/CloudFormation/123412341234/Aa/Bb/Cc',
107+
});
108+
expect(spyConsoleError).toHaveBeenCalledTimes(1);
109+
expect(spyConsoleError).toHaveBeenCalledWith('An error occurred while '
110+
+ 'publishing metrics: An error occurred (InternalServiceError) '
111+
+ 'when calling the PutMetricData operation: '
112+
);
113+
});
114+
115+
test('publish exception metric', () => {
116+
const proxy = new MetricsPublisherProxy(ACCOUNT_ID, RESOURCE_TYPE);
117+
proxy.addMetricsPublisher(session);
118+
proxy.publishExceptionMetric( MOCK_DATE, Action.Create, new Error('fake-err'));
119+
expect(putMetricData).toHaveBeenCalledTimes(1);
120+
expect(putMetricData).toHaveBeenCalledWith({
121+
MetricData: [{
122+
Dimensions: [
123+
{
124+
Name: 'DimensionKeyActionType',
125+
Value: 'CREATE',
126+
},
127+
{
128+
Name: 'DimensionKeyExceptionType',
129+
Value: 'Error',
130+
},
131+
{
132+
Name: 'DimensionKeyResourceType',
133+
Value: 'Aa::Bb::Cc',
134+
},
135+
],
136+
MetricName: MetricTypes.HandlerException,
137+
Timestamp: MOCK_DATE,
138+
Unit: StandardUnit.Count,
139+
Value: 1.0,
140+
}],
141+
Namespace: 'AWS/CloudFormation/123412341234/Aa/Bb/Cc',
142+
});
143+
});
144+
145+
test('publish invocation metric', () => {
146+
const proxy = new MetricsPublisherProxy(ACCOUNT_ID, RESOURCE_TYPE);
147+
proxy.addMetricsPublisher(session);
148+
proxy.publishInvocationMetric( MOCK_DATE, Action.Create);
149+
expect(putMetricData).toHaveBeenCalledTimes(1);
150+
expect(putMetricData).toHaveBeenCalledWith({
151+
MetricData: [{
152+
Dimensions: [
153+
{
154+
Name: 'DimensionKeyActionType',
155+
Value: 'CREATE',
156+
},
157+
{
158+
Name: 'DimensionKeyResourceType',
159+
Value: 'Aa::Bb::Cc',
160+
},
161+
],
162+
MetricName: MetricTypes.HandlerInvocationCount,
163+
Timestamp: MOCK_DATE,
164+
Unit: StandardUnit.Count,
165+
Value: 1.0,
166+
}],
167+
Namespace: 'AWS/CloudFormation/123412341234/Aa/Bb/Cc',
168+
});
169+
});
170+
171+
test('publish duration metric', () => {
172+
const proxy = new MetricsPublisherProxy(ACCOUNT_ID, RESOURCE_TYPE);
173+
proxy.addMetricsPublisher(session);
174+
proxy.publishDurationMetric( MOCK_DATE, Action.Create, 100);
175+
expect(putMetricData).toHaveBeenCalledTimes(1);
176+
expect(putMetricData).toHaveBeenCalledWith({
177+
MetricData: [{
178+
Dimensions: [
179+
{
180+
Name: 'DimensionKeyActionType',
181+
Value: 'CREATE',
182+
},
183+
{
184+
Name: 'DimensionKeyResourceType',
185+
Value: 'Aa::Bb::Cc',
186+
},
187+
],
188+
MetricName: MetricTypes.HandlerInvocationDuration,
189+
Timestamp: MOCK_DATE,
190+
Unit: StandardUnit.Milliseconds,
191+
Value: 100,
192+
}],
193+
Namespace: 'AWS/CloudFormation/123412341234/Aa/Bb/Cc',
194+
});
195+
});
196+
197+
198+
test('publish log delivery exception metric', () => {
199+
const proxy = new MetricsPublisherProxy(ACCOUNT_ID, RESOURCE_TYPE);
200+
proxy.addMetricsPublisher(session);
201+
proxy.publishLogDeliveryExceptionMetric( MOCK_DATE, new TypeError('test'));
202+
expect(putMetricData).toHaveBeenCalledTimes(1);
203+
expect(putMetricData).toHaveBeenCalledWith({
204+
MetricData: [{
205+
Dimensions: [
206+
{
207+
Name: 'DimensionKeyActionType',
208+
Value: 'ProviderLogDelivery',
209+
},
210+
{
211+
Name: 'DimensionKeyExceptionType',
212+
Value: 'TypeError',
213+
},
214+
{
215+
Name: 'DimensionKeyResourceType',
216+
Value: 'Aa::Bb::Cc',
217+
},
218+
],
219+
MetricName: MetricTypes.HandlerException,
220+
Timestamp: MOCK_DATE,
221+
Unit: StandardUnit.Count,
222+
Value: 1.0,
223+
}],
224+
Namespace: 'AWS/CloudFormation/123412341234/Aa/Bb/Cc',
225+
});
226+
});
227+
228+
test('metrics publisher proxy add metrics publisher null safe', () => {
229+
const proxy = new MetricsPublisherProxy(ACCOUNT_ID, RESOURCE_TYPE);
230+
proxy.addMetricsPublisher(null);
231+
expect(proxy['publishers']).toMatchObject([]);
232+
});
233+
});

0 commit comments

Comments
 (0)