@aws-blocks/bb-metrics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { test, describe, beforeEach, afterEach } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { Metrics, MetricsErrors } from './index.mock.js';
6
+ // ── Helpers ─────────────────────────────────────────────────────────────────
7
+ let stdoutLines = [];
8
+ let origStdoutWrite;
9
+ beforeEach(() => {
10
+ stdoutLines = [];
11
+ origStdoutWrite = process.stdout.write;
12
+ process.stdout.write = ((chunk) => {
13
+ stdoutLines.push(String(chunk));
14
+ return true;
15
+ });
16
+ });
17
+ afterEach(() => {
18
+ process.stdout.write = origStdoutWrite;
19
+ });
20
+ function getEmfDoc(index = 0) {
21
+ return JSON.parse(stdoutLines[index].trim());
22
+ }
23
+ const fakeScope = { id: 'root' };
24
+ // ── Basic Emit ──────────────────────────────────────────────────────────────
25
+ describe('basic emit', () => {
26
+ test('emit writes valid EMF JSON to stdout', () => {
27
+ const m = new Metrics(fakeScope, 'app');
28
+ m.emit('RequestCount', 1);
29
+ assert.strictEqual(stdoutLines.length, 1);
30
+ const doc = getEmfDoc();
31
+ assert.ok(doc._aws);
32
+ assert.ok(doc._aws.Timestamp);
33
+ assert.ok(Array.isArray(doc._aws.CloudWatchMetrics));
34
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].Name, 'RequestCount');
35
+ assert.strictEqual(doc.RequestCount, 1);
36
+ });
37
+ test('emit uses default namespace from scope fullId', () => {
38
+ const m = new Metrics(fakeScope, 'metrics');
39
+ m.emit('Count', 1);
40
+ const doc = getEmfDoc();
41
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'root-metrics');
42
+ });
43
+ test('emit uses custom namespace when provided', () => {
44
+ const m = new Metrics(fakeScope, 'metrics', { namespace: 'Custom/NS' });
45
+ m.emit('Count', 1);
46
+ const doc = getEmfDoc();
47
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'Custom/NS');
48
+ });
49
+ test('emit defaults unit to None', () => {
50
+ const m = new Metrics(fakeScope, 'app');
51
+ m.emit('Count', 5);
52
+ const doc = getEmfDoc();
53
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].Unit, 'None');
54
+ });
55
+ test('emit with unit sets the correct unit', () => {
56
+ const m = new Metrics(fakeScope, 'app');
57
+ m.emit('Latency', 42, { unit: 'Milliseconds' });
58
+ const doc = getEmfDoc();
59
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].Unit, 'Milliseconds');
60
+ });
61
+ test('emit with custom timestamp uses it', () => {
62
+ const m = new Metrics(fakeScope, 'app');
63
+ const ts = new Date('2024-06-15T10:30:00.000Z');
64
+ m.emit('Count', 1, { timestamp: ts });
65
+ const doc = getEmfDoc();
66
+ assert.strictEqual(doc._aws.Timestamp, ts.getTime());
67
+ });
68
+ test('emit with high resolution sets StorageResolution to 1', () => {
69
+ const m = new Metrics(fakeScope, 'app');
70
+ m.emit('Latency', 5, { resolution: 'high' });
71
+ const doc = getEmfDoc();
72
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution, 1);
73
+ });
74
+ test('emit with standard resolution sets StorageResolution to 60', () => {
75
+ const m = new Metrics(fakeScope, 'app');
76
+ m.emit('Count', 1, { resolution: 'standard' });
77
+ const doc = getEmfDoc();
78
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution, 60);
79
+ });
80
+ test('emit defaults resolution to standard (60)', () => {
81
+ const m = new Metrics(fakeScope, 'app');
82
+ m.emit('Count', 1);
83
+ const doc = getEmfDoc();
84
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution, 60);
85
+ });
86
+ });
87
+ // ── Dimensions ──────────────────────────────────────────────────────────────
88
+ describe('dimensions', () => {
89
+ test('emit with dimensions embeds them as top-level keys', () => {
90
+ const m = new Metrics(fakeScope, 'app');
91
+ m.emit('Count', 1, { dimensions: { service: 'orders', env: 'prod' } });
92
+ const doc = getEmfDoc();
93
+ assert.strictEqual(doc.service, 'orders');
94
+ assert.strictEqual(doc.env, 'prod');
95
+ assert.deepStrictEqual(doc._aws.CloudWatchMetrics[0].Dimensions, [['service', 'env']]);
96
+ });
97
+ test('defaultDimensions are included in every emit', () => {
98
+ const m = new Metrics(fakeScope, 'app', {
99
+ defaultDimensions: { service: 'api', region: 'us-east-1' },
100
+ });
101
+ m.emit('Count', 1);
102
+ const doc = getEmfDoc();
103
+ assert.strictEqual(doc.service, 'api');
104
+ assert.strictEqual(doc.region, 'us-east-1');
105
+ });
106
+ test('per-emit dimensions override defaultDimensions on conflict', () => {
107
+ const m = new Metrics(fakeScope, 'app', {
108
+ defaultDimensions: { env: 'prod' },
109
+ });
110
+ m.emit('Count', 1, { dimensions: { env: 'staging' } });
111
+ const doc = getEmfDoc();
112
+ assert.strictEqual(doc.env, 'staging');
113
+ });
114
+ test('per-emit dimensions merge with defaultDimensions', () => {
115
+ const m = new Metrics(fakeScope, 'app', {
116
+ defaultDimensions: { service: 'orders' },
117
+ });
118
+ m.emit('Count', 1, { dimensions: { endpoint: '/api' } });
119
+ const doc = getEmfDoc();
120
+ assert.strictEqual(doc.service, 'orders');
121
+ assert.strictEqual(doc.endpoint, '/api');
122
+ });
123
+ test('emit without dimensions uses only defaultDimensions', () => {
124
+ const m = new Metrics(fakeScope, 'app', {
125
+ defaultDimensions: { service: 'api' },
126
+ });
127
+ m.emit('Count', 1);
128
+ const doc = getEmfDoc();
129
+ assert.strictEqual(doc.service, 'api');
130
+ assert.deepStrictEqual(doc._aws.CloudWatchMetrics[0].Dimensions, [['service']]);
131
+ });
132
+ test('emit with no dimensions and no defaults uses empty array', () => {
133
+ const m = new Metrics(fakeScope, 'app');
134
+ m.emit('Count', 1);
135
+ const doc = getEmfDoc();
136
+ assert.deepStrictEqual(doc._aws.CloudWatchMetrics[0].Dimensions, [[]]);
137
+ });
138
+ });
139
+ // ── emitBatch ───────────────────────────────────────────────────────────────
140
+ describe('emitBatch', () => {
141
+ test('emitBatch writes multiple metrics in one EMF doc (same dimensions)', () => {
142
+ const m = new Metrics(fakeScope, 'app');
143
+ m.emitBatch([
144
+ { name: 'Count', value: 1, unit: 'Count' },
145
+ { name: 'Latency', value: 42, unit: 'Milliseconds' },
146
+ ]);
147
+ assert.strictEqual(stdoutLines.length, 1);
148
+ const doc = getEmfDoc();
149
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics.length, 2);
150
+ assert.strictEqual(doc.Count, 1);
151
+ assert.strictEqual(doc.Latency, 42);
152
+ });
153
+ test('emitBatch groups by dimension set', () => {
154
+ const m = new Metrics(fakeScope, 'app');
155
+ m.emitBatch([
156
+ { name: 'Count', value: 1, dimensions: { endpoint: '/a' } },
157
+ { name: 'Errors', value: 0, dimensions: { endpoint: '/b' } },
158
+ { name: 'Latency', value: 10, dimensions: { endpoint: '/a' } },
159
+ ]);
160
+ // Two groups: /a and /b
161
+ assert.strictEqual(stdoutLines.length, 2);
162
+ const doc1 = getEmfDoc(0);
163
+ const doc2 = getEmfDoc(1);
164
+ // Group with /a has 2 metrics
165
+ assert.strictEqual(doc1.endpoint, '/a');
166
+ assert.strictEqual(doc1._aws.CloudWatchMetrics[0].Metrics.length, 2);
167
+ // Group with /b has 1 metric
168
+ assert.strictEqual(doc2.endpoint, '/b');
169
+ assert.strictEqual(doc2._aws.CloudWatchMetrics[0].Metrics.length, 1);
170
+ });
171
+ test('emitBatch applies defaultDimensions', () => {
172
+ const m = new Metrics(fakeScope, 'app', {
173
+ defaultDimensions: { service: 'api' },
174
+ });
175
+ m.emitBatch([{ name: 'Count', value: 1 }]);
176
+ const doc = getEmfDoc();
177
+ assert.strictEqual(doc.service, 'api');
178
+ });
179
+ test('emitBatch rejects > 100 metrics', () => {
180
+ const m = new Metrics(fakeScope, 'app');
181
+ const batch = Array.from({ length: 101 }, (_, i) => ({
182
+ name: `Metric${i}`, value: i,
183
+ }));
184
+ assert.throws(() => m.emitBatch(batch), (err) => err.name === MetricsErrors.BatchTooLarge);
185
+ });
186
+ test('emitBatch with exactly 100 metrics succeeds', () => {
187
+ const m = new Metrics(fakeScope, 'app');
188
+ const batch = Array.from({ length: 100 }, (_, i) => ({
189
+ name: `Metric${i}`, value: i,
190
+ }));
191
+ m.emitBatch(batch);
192
+ assert.strictEqual(stdoutLines.length, 1);
193
+ });
194
+ test('emitBatch with empty array writes nothing', () => {
195
+ const m = new Metrics(fakeScope, 'app');
196
+ m.emitBatch([]);
197
+ assert.strictEqual(stdoutLines.length, 0);
198
+ });
199
+ });
200
+ // ── child() ─────────────────────────────────────────────────────────────────
201
+ describe('child metrics', () => {
202
+ test('child returns a MetricsEmitter (not a Metrics instance)', () => {
203
+ const m = new Metrics(fakeScope, 'app');
204
+ const child = m.child({ endpoint: '/api' });
205
+ assert.ok(typeof child.emit === 'function');
206
+ assert.ok(typeof child.emitBatch === 'function');
207
+ assert.ok(typeof child.flush === 'function');
208
+ assert.ok(typeof child.child === 'function');
209
+ assert.ok(!(child instanceof Metrics));
210
+ });
211
+ test('child inherits defaultDimensions and merges its own', () => {
212
+ const m = new Metrics(fakeScope, 'app', {
213
+ defaultDimensions: { service: 'api' },
214
+ });
215
+ const child = m.child({ endpoint: '/users' });
216
+ child.emit('Count', 1);
217
+ const doc = getEmfDoc();
218
+ assert.strictEqual(doc.service, 'api');
219
+ assert.strictEqual(doc.endpoint, '/users');
220
+ });
221
+ test('child dimensions override parent defaults on conflict', () => {
222
+ const m = new Metrics(fakeScope, 'app', {
223
+ defaultDimensions: { env: 'prod' },
224
+ });
225
+ const child = m.child({ env: 'staging' });
226
+ child.emit('Count', 1);
227
+ const doc = getEmfDoc();
228
+ assert.strictEqual(doc.env, 'staging');
229
+ });
230
+ test('child inherits namespace', () => {
231
+ const m = new Metrics(fakeScope, 'app', { namespace: 'MyApp/Test' });
232
+ const child = m.child({ req: '123' });
233
+ child.emit('Count', 1);
234
+ const doc = getEmfDoc();
235
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'MyApp/Test');
236
+ });
237
+ test('nested children merge contexts correctly', () => {
238
+ const m = new Metrics(fakeScope, 'app', {
239
+ defaultDimensions: { a: '1' },
240
+ });
241
+ const child1 = m.child({ b: '2' });
242
+ const child2 = child1.child({ c: '3' });
243
+ child2.emit('Count', 1);
244
+ const doc = getEmfDoc();
245
+ assert.strictEqual(doc.a, '1');
246
+ assert.strictEqual(doc.b, '2');
247
+ assert.strictEqual(doc.c, '3');
248
+ });
249
+ test('child emitBatch works correctly', () => {
250
+ const m = new Metrics(fakeScope, 'app', {
251
+ defaultDimensions: { service: 'api' },
252
+ });
253
+ const child = m.child({ endpoint: '/orders' });
254
+ child.emitBatch([
255
+ { name: 'Count', value: 1 },
256
+ { name: 'Latency', value: 50 },
257
+ ]);
258
+ const doc = getEmfDoc();
259
+ assert.strictEqual(doc.service, 'api');
260
+ assert.strictEqual(doc.endpoint, '/orders');
261
+ assert.strictEqual(doc.Count, 1);
262
+ assert.strictEqual(doc.Latency, 50);
263
+ });
264
+ });
265
+ // ── Validation ──────────────────────────────────────────────────────────────
266
+ describe('validation', () => {
267
+ test('emit rejects empty metric name', () => {
268
+ const m = new Metrics(fakeScope, 'app');
269
+ assert.throws(() => m.emit('', 1), (err) => err.name === MetricsErrors.InvalidMetricName);
270
+ });
271
+ test('emit rejects metric name > 1024 chars', () => {
272
+ const m = new Metrics(fakeScope, 'app');
273
+ assert.throws(() => m.emit('x'.repeat(1025), 1), (err) => err.name === MetricsErrors.InvalidMetricName);
274
+ });
275
+ test('emit accepts metric name of exactly 1024 chars', () => {
276
+ const m = new Metrics(fakeScope, 'app');
277
+ m.emit('x'.repeat(1024), 1);
278
+ assert.strictEqual(stdoutLines.length, 1);
279
+ });
280
+ test('emit rejects > 30 dimensions (including defaults)', () => {
281
+ const dims = {};
282
+ for (let i = 0; i < 31; i++)
283
+ dims[`key${i}`] = `val${i}`;
284
+ const m = new Metrics(fakeScope, 'app');
285
+ assert.throws(() => m.emit('Count', 1, { dimensions: dims }), (err) => err.name === MetricsErrors.InvalidDimensions);
286
+ });
287
+ test('emit rejects empty dimension key', () => {
288
+ const m = new Metrics(fakeScope, 'app');
289
+ assert.throws(() => m.emit('Count', 1, { dimensions: { '': 'value' } }), (err) => err.name === MetricsErrors.InvalidDimensions);
290
+ });
291
+ test('emit rejects empty dimension value', () => {
292
+ const m = new Metrics(fakeScope, 'app');
293
+ assert.throws(() => m.emit('Count', 1, { dimensions: { key: '' } }), (err) => err.name === MetricsErrors.InvalidDimensions);
294
+ });
295
+ test('emit rejects dimension key > 1024 chars', () => {
296
+ const m = new Metrics(fakeScope, 'app');
297
+ assert.throws(() => m.emit('Count', 1, { dimensions: { ['k'.repeat(1025)]: 'v' } }), (err) => err.name === MetricsErrors.InvalidDimensions);
298
+ });
299
+ test('emit rejects dimension value > 1024 chars', () => {
300
+ const m = new Metrics(fakeScope, 'app');
301
+ assert.throws(() => m.emit('Count', 1, { dimensions: { key: 'v'.repeat(1025) } }), (err) => err.name === MetricsErrors.InvalidDimensions);
302
+ });
303
+ test('emitBatch validates each metric name', () => {
304
+ const m = new Metrics(fakeScope, 'app');
305
+ assert.throws(() => m.emitBatch([{ name: '', value: 1 }]), (err) => err.name === MetricsErrors.InvalidMetricName);
306
+ });
307
+ test('emitBatch validates dimensions', () => {
308
+ const m = new Metrics(fakeScope, 'app');
309
+ const dims = {};
310
+ for (let i = 0; i < 31; i++)
311
+ dims[`key${i}`] = `val${i}`;
312
+ assert.throws(() => m.emitBatch([{ name: 'Count', value: 1, dimensions: dims }]), (err) => err.name === MetricsErrors.InvalidDimensions);
313
+ });
314
+ });
315
+ // ── Namespace ───────────────────────────────────────────────────────────────
316
+ describe('namespace', () => {
317
+ test('default namespace is derived from scope fullId', () => {
318
+ const m = new Metrics({ id: 'myapp' }, 'metrics');
319
+ m.emit('Count', 1);
320
+ const doc = getEmfDoc();
321
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'myapp-metrics');
322
+ });
323
+ test('custom namespace overrides default', () => {
324
+ const m = new Metrics(fakeScope, 'app', { namespace: 'Custom/NS' });
325
+ m.emit('Count', 1);
326
+ const doc = getEmfDoc();
327
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'Custom/NS');
328
+ });
329
+ test('fromExisting namespace takes precedence over custom', () => {
330
+ const m = new Metrics(fakeScope, 'app', {
331
+ namespace: 'Ignored',
332
+ metrics: Metrics.fromExisting('External/NS'),
333
+ });
334
+ m.emit('Count', 1);
335
+ const doc = getEmfDoc();
336
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'External/NS');
337
+ });
338
+ test('options.namespace is used when fromExisting is not provided', () => {
339
+ const m = new Metrics(fakeScope, 'app', { namespace: 'OptionNS' });
340
+ m.emit('Count', 1);
341
+ const doc = getEmfDoc();
342
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'OptionNS');
343
+ });
344
+ });
345
+ // ── Namespace Validation ────────────────────────────────────────────────────
346
+ describe('namespace validation', () => {
347
+ test('rejects empty namespace', () => {
348
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: '' }), (err) => err.name === MetricsErrors.InvalidNamespace);
349
+ });
350
+ test('rejects whitespace-only namespace', () => {
351
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: ' ' }), (err) => err.name === MetricsErrors.InvalidNamespace);
352
+ });
353
+ test('rejects namespace > 256 characters', () => {
354
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: 'x'.repeat(257) }), (err) => err.name === MetricsErrors.InvalidNamespace);
355
+ });
356
+ test('accepts namespace of exactly 256 characters', () => {
357
+ const m = new Metrics(fakeScope, 'app', { namespace: 'x'.repeat(256) });
358
+ m.emit('Count', 1);
359
+ assert.strictEqual(stdoutLines.length, 1);
360
+ });
361
+ test('rejects namespace with invalid characters', () => {
362
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: 'My@Namespace!' }), (err) => err.name === MetricsErrors.InvalidNamespace);
363
+ });
364
+ test('rejects namespace with curly braces', () => {
365
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: 'My{Namespace}' }), (err) => err.name === MetricsErrors.InvalidNamespace);
366
+ });
367
+ test('rejects namespace starting with AWS/', () => {
368
+ assert.throws(() => new Metrics(fakeScope, 'app', { namespace: 'AWS/MyService' }), (err) => err.name === MetricsErrors.InvalidNamespace);
369
+ });
370
+ test('accepts namespace with AWS not at start', () => {
371
+ const m = new Metrics(fakeScope, 'app', { namespace: 'MyApp/AWS/Metrics' });
372
+ m.emit('Count', 1);
373
+ assert.strictEqual(stdoutLines.length, 1);
374
+ });
375
+ test('accepts valid namespace with alphanumeric chars', () => {
376
+ const m = new Metrics(fakeScope, 'app', { namespace: 'MyApp123' });
377
+ m.emit('Count', 1);
378
+ const doc = getEmfDoc();
379
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Namespace, 'MyApp123');
380
+ });
381
+ test('accepts valid namespace with dots', () => {
382
+ const m = new Metrics(fakeScope, 'app', { namespace: 'com.myapp.metrics' });
383
+ m.emit('Count', 1);
384
+ assert.strictEqual(stdoutLines.length, 1);
385
+ });
386
+ test('accepts valid namespace with hyphens and underscores', () => {
387
+ const m = new Metrics(fakeScope, 'app', { namespace: 'my-app_metrics' });
388
+ m.emit('Count', 1);
389
+ assert.strictEqual(stdoutLines.length, 1);
390
+ });
391
+ test('accepts valid namespace with slashes', () => {
392
+ const m = new Metrics(fakeScope, 'app', { namespace: 'MyOrg/MyApp/Prod' });
393
+ m.emit('Count', 1);
394
+ assert.strictEqual(stdoutLines.length, 1);
395
+ });
396
+ test('accepts valid namespace with hash and colon', () => {
397
+ const m = new Metrics(fakeScope, 'app', { namespace: 'App#v2:metrics' });
398
+ m.emit('Count', 1);
399
+ assert.strictEqual(stdoutLines.length, 1);
400
+ });
401
+ test('accepts valid namespace with spaces', () => {
402
+ const m = new Metrics(fakeScope, 'app', { namespace: 'My App Metrics' });
403
+ m.emit('Count', 1);
404
+ assert.strictEqual(stdoutLines.length, 1);
405
+ });
406
+ test('fromExisting also validates namespace', () => {
407
+ assert.throws(() => new Metrics(fakeScope, 'app', { metrics: Metrics.fromExisting('AWS/Reserved') }), (err) => err.name === MetricsErrors.InvalidNamespace);
408
+ });
409
+ });
410
+ // ── fromExisting ────────────────────────────────────────────────────────────
411
+ describe('fromExisting', () => {
412
+ test('returns ExternalMetricsRef with namespace', () => {
413
+ const ref = Metrics.fromExisting('MyApp/Production');
414
+ assert.strictEqual(ref.namespace, 'MyApp/Production');
415
+ assert.strictEqual(ref.__brand, 'ExternalMetricsRef');
416
+ });
417
+ });
418
+ // ── flush ───────────────────────────────────────────────────────────────────
419
+ describe('flush', () => {
420
+ test('flush does not throw', () => {
421
+ const m = new Metrics(fakeScope, 'app');
422
+ m.flush(); // no-op
423
+ });
424
+ });
425
+ // ── Error Constants ─────────────────────────────────────────────────────────
426
+ describe('error constants', () => {
427
+ test('MetricsErrors has expected constants', () => {
428
+ assert.strictEqual(MetricsErrors.InvalidMetricName, 'InvalidMetricNameException');
429
+ assert.strictEqual(MetricsErrors.InvalidDimensions, 'InvalidDimensionsException');
430
+ assert.strictEqual(MetricsErrors.BatchTooLarge, 'BatchTooLargeException');
431
+ });
432
+ });
433
+ // ── EMF Format Correctness ──────────────────────────────────────────────────
434
+ describe('EMF format', () => {
435
+ test('EMF document has correct _aws.CloudWatchMetrics structure', () => {
436
+ const m = new Metrics(fakeScope, 'app', { namespace: 'Test/NS' });
437
+ m.emit('Latency', 100, {
438
+ unit: 'Milliseconds',
439
+ dimensions: { endpoint: '/api' },
440
+ resolution: 'high',
441
+ });
442
+ const doc = getEmfDoc();
443
+ assert.ok(typeof doc._aws.Timestamp === 'number');
444
+ assert.strictEqual(doc._aws.CloudWatchMetrics.length, 1);
445
+ const cw = doc._aws.CloudWatchMetrics[0];
446
+ assert.strictEqual(cw.Namespace, 'Test/NS');
447
+ assert.deepStrictEqual(cw.Dimensions, [['endpoint']]);
448
+ assert.strictEqual(cw.Metrics.length, 1);
449
+ assert.strictEqual(cw.Metrics[0].Name, 'Latency');
450
+ assert.strictEqual(cw.Metrics[0].Unit, 'Milliseconds');
451
+ assert.strictEqual(cw.Metrics[0].StorageResolution, 1);
452
+ assert.strictEqual(doc.endpoint, '/api');
453
+ assert.strictEqual(doc.Latency, 100);
454
+ });
455
+ test('EMF doc with multiple metrics in batch has all values at top level', () => {
456
+ const m = new Metrics(fakeScope, 'app');
457
+ m.emitBatch([
458
+ { name: 'A', value: 10, unit: 'Count' },
459
+ { name: 'B', value: 20, unit: 'Bytes' },
460
+ { name: 'C', value: 30, unit: 'Milliseconds' },
461
+ ]);
462
+ const doc = getEmfDoc();
463
+ assert.strictEqual(doc.A, 10);
464
+ assert.strictEqual(doc.B, 20);
465
+ assert.strictEqual(doc.C, 30);
466
+ assert.strictEqual(doc._aws.CloudWatchMetrics[0].Metrics.length, 3);
467
+ });
468
+ test('EMF output is one JSON line per document (no extra newlines)', () => {
469
+ const m = new Metrics(fakeScope, 'app');
470
+ m.emit('Count', 1);
471
+ assert.strictEqual(stdoutLines.length, 1);
472
+ assert.ok(stdoutLines[0].endsWith('\n'));
473
+ // Should be valid JSON without the trailing newline
474
+ JSON.parse(stdoutLines[0].trim());
475
+ });
476
+ });
477
+ // ── Scope Integration ───────────────────────────────────────────────────────
478
+ describe('scope integration', () => {
479
+ test('Metrics extends Scope (has id and parent)', () => {
480
+ const m = new Metrics(fakeScope, 'metrics-id');
481
+ assert.strictEqual(m.id, 'metrics-id');
482
+ assert.strictEqual(m.parent, fakeScope);
483
+ });
484
+ test('fullId includes parent scope', () => {
485
+ const m = new Metrics(fakeScope, 'child');
486
+ assert.strictEqual(m.fullId, 'root-child');
487
+ });
488
+ test('Metrics has core metric methods', () => {
489
+ const m = new Metrics(fakeScope, 'app');
490
+ assert.ok(typeof m.emit === 'function');
491
+ assert.ok(typeof m.emitBatch === 'function');
492
+ assert.ok(typeof m.flush === 'function');
493
+ assert.ok(typeof m.child === 'function');
494
+ });
495
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared types for the Metrics building block.
3
+ * This file has zero runtime dependencies — types only.
4
+ */
5
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
6
+ /**
7
+ * Units supported by CloudWatch. Using a unit enables automatic conversions
8
+ * in dashboards (e.g., Bytes → Megabytes) and clearer Y-axis labels.
9
+ *
10
+ * Covers the most common use cases. For unlisted units, use 'None'.
11
+ */
12
+ export type MetricUnit = 'Count' | 'Seconds' | 'Milliseconds' | 'Microseconds' | 'Bytes' | 'Kilobytes' | 'Megabytes' | 'Gigabytes' | 'Percent' | 'Bits/Second' | 'None';
13
+ /**
14
+ * Storage resolution for a metric data point.
15
+ * - 'standard' (60s) — default, lower cost, 15-day retention at full resolution
16
+ * - 'high' (1s) — higher cost, 3-hour retention at full resolution then aggregated
17
+ */
18
+ export type MetricResolution = 'standard' | 'high';
19
+ /**
20
+ * Configuration for the Metrics building block.
21
+ */
22
+ export interface MetricsOptions {
23
+ /**
24
+ * CloudWatch namespace for all metrics emitted by this instance.
25
+ * Namespaces group related metrics in CloudWatch dashboards and alarms.
26
+ * Defaults to the scope's `fullId` (e.g., 'myapp-appMetrics').
27
+ */
28
+ namespace?: string;
29
+ /**
30
+ * Dimensions applied to every metric emitted by this instance.
31
+ * Useful for shared context like service name or environment.
32
+ * Per-emit dimensions are merged on top of these (per-emit wins on conflict).
33
+ */
34
+ defaultDimensions?: Record<string, string>;
35
+ /**
36
+ * Wrap an existing CloudWatch namespace instead of creating one.
37
+ * When set, `namespace` is ignored.
38
+ */
39
+ metrics?: ExternalMetricsRef;
40
+ /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
41
+ logger?: ChildLogger;
42
+ }
43
+ /**
44
+ * Options for a single metric emission.
45
+ */
46
+ export interface EmitOptions {
47
+ /** Unit of the metric value. Defaults to 'None'. */
48
+ unit?: MetricUnit;
49
+ /**
50
+ * Dimensions to attach to this data point (max 30 total including defaults).
51
+ * Merged with `defaultDimensions` — per-emit dimensions take precedence.
52
+ */
53
+ dimensions?: Record<string, string>;
54
+ /** Timestamp for the data point. Defaults to now. */
55
+ timestamp?: Date;
56
+ /**
57
+ * Storage resolution. 'standard' = 60-second aggregation (default).
58
+ * 'high' = 1-second aggregation (higher cost, useful for spike detection).
59
+ */
60
+ resolution?: MetricResolution;
61
+ }
62
+ /**
63
+ * A single metric data point for batch emission.
64
+ */
65
+ export interface MetricDatum {
66
+ /** Metric name (non-empty, max 1024 characters). */
67
+ name: string;
68
+ /** Numeric value. */
69
+ value: number;
70
+ /** Unit of the metric value. Defaults to 'None'. */
71
+ unit?: MetricUnit;
72
+ /** Dimensions to attach (max 30 total including defaults). */
73
+ dimensions?: Record<string, string>;
74
+ /** Timestamp for the data point. Defaults to now. */
75
+ timestamp?: Date;
76
+ /** Storage resolution. Defaults to 'standard'. */
77
+ resolution?: MetricResolution;
78
+ }
79
+ /**
80
+ * Reference to an existing CloudWatch namespace not managed by this BB.
81
+ * Created via `Metrics.fromExisting()`.
82
+ */
83
+ export interface ExternalMetricsRef {
84
+ readonly __brand: 'ExternalMetricsRef';
85
+ readonly namespace: string;
86
+ }
87
+ /**
88
+ * A child metrics instance with inherited dimensions and namespace.
89
+ * Provides the same metric emission methods but is not a Scope node.
90
+ */
91
+ export interface MetricsEmitter {
92
+ emit(name: string, value: number, options?: EmitOptions): void;
93
+ emitBatch(metrics: MetricDatum[]): void;
94
+ flush(): void;
95
+ child(dimensions: Record<string, string>): MetricsEmitter;
96
+ }
97
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GACnB,OAAO,GACP,SAAS,GACT,cAAc,GACd,cAAc,GACd,OAAO,GACP,WAAW,GACX,WAAW,GACX,WAAW,GACX,SAAS,GACT,aAAa,GACb,MAAM,CAAC;AAEV;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAE3C;;;OAGG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,oDAAoD;IACpD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,qDAAqD;IACrD,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,qDAAqD;IACrD,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,kDAAkD;IAClD,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/D,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACxC,KAAK,IAAI,IAAI,CAAC;IACd,KAAK,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,cAAc,CAAC;CAC1D"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Validate a metric name against CloudWatch constraints.
3
+ * - Must be non-empty
4
+ * - Max 1024 characters
5
+ */
6
+ export declare function validateMetricName(name: string): void;
7
+ /**
8
+ * Validate dimensions against CloudWatch constraints.
9
+ * - Max 30 dimension key-value pairs
10
+ * - Keys and values must be non-empty
11
+ * - Keys and values max 1024 characters each
12
+ */
13
+ export declare function validateDimensions(dimensions: Record<string, string>): void;
14
+ /**
15
+ * Validate batch size (max 100 metrics per EMF document).
16
+ */
17
+ export declare function validateBatchSize(count: number): void;
18
+ /**
19
+ * Validate a namespace against CloudWatch constraints.
20
+ * - Must be non-empty (at least one non-whitespace character)
21
+ * - Max 256 characters
22
+ * - Only valid chars: [a-zA-Z0-9._#:/ -]
23
+ * - Must not start with "AWS/" (reserved for AWS services)
24
+ */
25
+ export declare function validateNamespace(namespace: string): void;
26
+ /**
27
+ * Merge default dimensions with per-emit dimensions.
28
+ * Per-emit dimensions take precedence on key conflict.
29
+ */
30
+ export declare function mergeDimensions(defaults: Record<string, string>, overrides?: Record<string, string>): Record<string, string>;
31
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAWA;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAOrD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAmB3E;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIrD;AAKD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAazD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAGxB"}