@tryghost/metrics 3.4.2 → 3.5.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.
- package/README.md +37 -0
- package/lib/GhostMetrics.d.ts +39 -5
- package/lib/GhostMetrics.js +85 -9
- package/lib/metrics.d.ts +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -29,6 +29,42 @@ const custom = new metrics.GhostMetrics({
|
|
|
29
29
|
});
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
### Sampling
|
|
33
|
+
|
|
34
|
+
High-volume metrics can be sampled so only a proportion of them are shipped.
|
|
35
|
+
`sampleRate` is a number between 0 and 1, and defaults to `1` (ship
|
|
36
|
+
everything). Configure a default rate, per-metric rates, or both:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const metrics = require('@tryghost/metrics');
|
|
40
|
+
|
|
41
|
+
const sampled = new metrics.GhostMetrics({
|
|
42
|
+
metrics: {
|
|
43
|
+
transports: ['elasticsearch'],
|
|
44
|
+
sampleRate: 0.5,
|
|
45
|
+
sampleRates: {
|
|
46
|
+
'request-duration': 0.01,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A rate can also be overridden per call, which takes precedence over both:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
await sampled.metric('request-duration', duration, { sampleRate: 0.001 });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Sampled metrics carry the rate they survived, so consumers can scale counts
|
|
59
|
+
back up by dividing by it. The Elasticsearch shipper writes it as a
|
|
60
|
+
`sampleRate` field, and the stdout shipper appends it to the log line. Metrics
|
|
61
|
+
shipped at a rate of `1` are not tagged, so an absent `sampleRate` means the
|
|
62
|
+
metric was not sampled.
|
|
63
|
+
|
|
64
|
+
An invalid configured rate throws when the instance is constructed. An invalid
|
|
65
|
+
per-call rate is ignored in favour of the configured rate, so a bad call site
|
|
66
|
+
cannot break the code path it is measuring.
|
|
67
|
+
|
|
32
68
|
### Types
|
|
33
69
|
|
|
34
70
|
Types ship with the package. `GhostMetrics` is exported as both a value (the
|
|
@@ -40,6 +76,7 @@ import type {
|
|
|
40
76
|
GhostMetrics as GhostMetricsInstance,
|
|
41
77
|
GhostMetricsOptions,
|
|
42
78
|
MetricsOptions,
|
|
79
|
+
MetricOptions,
|
|
43
80
|
ElasticsearchOptions,
|
|
44
81
|
MetricShipper,
|
|
45
82
|
} from '@tryghost/metrics';
|
package/lib/GhostMetrics.d.ts
CHANGED
|
@@ -20,6 +20,24 @@ interface MetricsOptions {
|
|
|
20
20
|
transports?: string[];
|
|
21
21
|
/** Property bag of metadata values shipped alongside each metric value. */
|
|
22
22
|
metadata?: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Default proportion of metrics to ship, between 0 and 1. Throws at construction
|
|
25
|
+
* time if it is not a number in that range. @default 1
|
|
26
|
+
*/
|
|
27
|
+
sampleRate?: number;
|
|
28
|
+
/** Per-metric sample rate overrides, keyed by metric name. Same validation as `sampleRate`. */
|
|
29
|
+
sampleRates?: Record<string, number>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-call options accepted by {@link GhostMetrics.metric}.
|
|
34
|
+
*/
|
|
35
|
+
interface MetricOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Proportion of calls to ship, between 0 and 1, taking precedence over any configured
|
|
38
|
+
* rate. Values outside that range are ignored rather than throwing.
|
|
39
|
+
*/
|
|
40
|
+
sampleRate?: number;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
/**
|
|
@@ -37,9 +55,10 @@ interface GhostMetricsOptions {
|
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
/**
|
|
40
|
-
* A single metric shipper function keyed by transport name.
|
|
58
|
+
* A single metric shipper function keyed by transport name. Receives the sample rate the
|
|
59
|
+
* metric survived, so transports can record it alongside the value.
|
|
41
60
|
*/
|
|
42
|
-
type MetricShipper = (name: string, value: unknown) => Promise<unknown>;
|
|
61
|
+
type MetricShipper = (name: string, value: unknown, sampleRate?: number) => Promise<unknown>;
|
|
43
62
|
|
|
44
63
|
/**
|
|
45
64
|
* Metric shipper class built on the loggingrc config used in Ghost projects.
|
|
@@ -50,6 +69,8 @@ declare class GhostMetrics {
|
|
|
50
69
|
mode: string;
|
|
51
70
|
transports: string[];
|
|
52
71
|
metadata: Record<string, unknown>;
|
|
72
|
+
sampleRate: number;
|
|
73
|
+
sampleRates: Record<string, number>;
|
|
53
74
|
shippers: Record<string, MetricShipper>;
|
|
54
75
|
|
|
55
76
|
constructor(options?: GhostMetricsOptions);
|
|
@@ -66,15 +87,28 @@ declare class GhostMetrics {
|
|
|
66
87
|
setupElasticsearchShipper(): void;
|
|
67
88
|
|
|
68
89
|
/**
|
|
69
|
-
*
|
|
90
|
+
* Resolve the sample rate for a metric: per-call override, then per-metric config,
|
|
91
|
+
* then the instance default.
|
|
92
|
+
*/
|
|
93
|
+
getSampleRate(name: string, options?: MetricOptions): number;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ship a metric through every configured transport, unless it is dropped by sampling.
|
|
70
97
|
* @param name Metric name, should be slugified for back-end compatibility (e.g. `"memory-usage"`).
|
|
71
98
|
* @param value Metric value; coerced to an object before being shipped.
|
|
99
|
+
* @param options Per-call options, e.g. a `sampleRate` override.
|
|
72
100
|
*/
|
|
73
|
-
metric(name: string, value: unknown): Promise<null>;
|
|
101
|
+
metric(name: string, value: unknown, options?: MetricOptions): Promise<null>;
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
declare namespace GhostMetrics {
|
|
77
|
-
export {
|
|
105
|
+
export {
|
|
106
|
+
ElasticsearchOptions,
|
|
107
|
+
MetricsOptions,
|
|
108
|
+
MetricOptions,
|
|
109
|
+
GhostMetricsOptions,
|
|
110
|
+
MetricShipper,
|
|
111
|
+
};
|
|
78
112
|
}
|
|
79
113
|
|
|
80
114
|
export = GhostMetrics;
|
package/lib/GhostMetrics.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
const jsonStringifySafe = require('json-stringify-safe');
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* @description Check a value is usable as a sample rate: a number between 0 and 1 inclusive
|
|
5
|
+
* @param {any} rate Candidate sample rate
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
function isValidSampleRate(rate) {
|
|
9
|
+
return typeof rate === 'number' && Number.isFinite(rate) && rate >= 0 && rate <= 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @description Validate a configured sample rate, defaulting to 1 (ship everything) when unset
|
|
14
|
+
* @param {any} rate Candidate sample rate
|
|
15
|
+
* @param {string} key Config key used in the error message
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
function normalizeSampleRate(rate, key) {
|
|
19
|
+
if (rate === undefined || rate === null) {
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!isValidSampleRate(rate)) {
|
|
24
|
+
throw new Error(`${key} must be a number between 0 and 1, got ${jsonStringifySafe(rate)}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return rate;
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
/**
|
|
4
31
|
* @description Metric shipper class built on the loggingrc config used in Ghost projects
|
|
5
32
|
*/
|
|
@@ -9,9 +36,11 @@ class GhostMetrics {
|
|
|
9
36
|
*
|
|
10
37
|
* domain: Metadata for metrics in shared databases.
|
|
11
38
|
* mode: Is used to print short or long log - used for stdout shipper.
|
|
12
|
-
* metrics.transports:
|
|
13
|
-
* metrics.metadata:
|
|
14
|
-
*
|
|
39
|
+
* metrics.transports: An array of transports for metric shipping (e.g. ['stdout', 'elasticsearch'])
|
|
40
|
+
* metrics.metadata: A property bag of metadata values to be shipped alongside the metric value
|
|
41
|
+
* metrics.sampleRate: Default proportion of metrics to ship, between 0 and 1 (defaults to 1, ship everything)
|
|
42
|
+
* metrics.sampleRates: Per-metric sample rate overrides, keyed by metric name
|
|
43
|
+
* elasticsearch: Elasticsearch transport configuration
|
|
15
44
|
* @param {object} options Bag of options
|
|
16
45
|
*/
|
|
17
46
|
constructor(options) {
|
|
@@ -20,12 +49,21 @@ class GhostMetrics {
|
|
|
20
49
|
this.domain = options.domain || 'localhost';
|
|
21
50
|
this.elasticsearch = options.elasticsearch || {};
|
|
22
51
|
this.mode = process.env.MODE || options.mode || 'short';
|
|
23
|
-
|
|
52
|
+
// Prototype-free so that metric names like "__proto__" are stored and read back
|
|
53
|
+
// as ordinary keys, and inherited properties are never mistaken for configured rates
|
|
54
|
+
this.sampleRates = Object.create(null);
|
|
55
|
+
if (options.metrics !== null && typeof options.metrics === 'object') {
|
|
24
56
|
this.transports = options.metrics.transports || [];
|
|
25
57
|
this.metadata = options.metrics.metadata || {};
|
|
58
|
+
this.sampleRate = normalizeSampleRate(options.metrics.sampleRate, 'metrics.sampleRate');
|
|
59
|
+
|
|
60
|
+
for (const [name, rate] of Object.entries(options.metrics.sampleRates || {})) {
|
|
61
|
+
this.sampleRates[name] = normalizeSampleRate(rate, `metrics.sampleRates.${name}`);
|
|
62
|
+
}
|
|
26
63
|
} else {
|
|
27
64
|
this.transports = [];
|
|
28
65
|
this.metadata = {};
|
|
66
|
+
this.sampleRate = 1;
|
|
29
67
|
}
|
|
30
68
|
|
|
31
69
|
// CASE: special env variable to enable long mode and level info
|
|
@@ -57,9 +95,12 @@ class GhostMetrics {
|
|
|
57
95
|
|
|
58
96
|
prettyStdOut.pipe(process.stdout);
|
|
59
97
|
|
|
60
|
-
this.shippers.stdout = (name, value) => {
|
|
98
|
+
this.shippers.stdout = (name, value, sampleRate) => {
|
|
99
|
+
// Only mention the sample rate when the metric is actually sampled, so unsampled output is unchanged
|
|
100
|
+
const suffix = sampleRate < 1 ? ` (sample rate: ${sampleRate})` : '';
|
|
101
|
+
|
|
61
102
|
prettyStdOut.write({
|
|
62
|
-
msg: `Metric ${name}: ${jsonStringifySafe(value)}`,
|
|
103
|
+
msg: `Metric ${name}: ${jsonStringifySafe(value)}${suffix}`,
|
|
63
104
|
level: 30, // Magic number, log level for info
|
|
64
105
|
});
|
|
65
106
|
|
|
@@ -85,7 +126,7 @@ class GhostMetrics {
|
|
|
85
126
|
proxy: 'proxy' in this.elasticsearch ? this.elasticsearch.proxy : null,
|
|
86
127
|
});
|
|
87
128
|
|
|
88
|
-
this.shippers.elasticsearch = (name, value) => {
|
|
129
|
+
this.shippers.elasticsearch = (name, value, sampleRate) => {
|
|
89
130
|
if (typeof value !== 'object') {
|
|
90
131
|
value = { value };
|
|
91
132
|
}
|
|
@@ -98,19 +139,54 @@ class GhostMetrics {
|
|
|
98
139
|
value.metadata = this.metadata;
|
|
99
140
|
}
|
|
100
141
|
|
|
142
|
+
// Sampled documents carry their rate so consumers can scale counts back up.
|
|
143
|
+
// An absent sampleRate means the metric was not sampled (i.e. a rate of 1).
|
|
144
|
+
if (sampleRate < 1) {
|
|
145
|
+
value.sampleRate = sampleRate;
|
|
146
|
+
}
|
|
147
|
+
|
|
101
148
|
return elasticSearch.index(value, `metrics-${name}`);
|
|
102
149
|
};
|
|
103
150
|
}
|
|
104
151
|
|
|
152
|
+
/**
|
|
153
|
+
* @description Resolve the sample rate for a metric, most specific config first
|
|
154
|
+
* Invalid per-call overrides are ignored rather than thrown, so a bad call site cannot
|
|
155
|
+
* take down the code path it is measuring. Invalid config throws at construction time.
|
|
156
|
+
* @param {string} name Name of the metric
|
|
157
|
+
* @param {object} [options] Per-call options bag
|
|
158
|
+
* @returns {number}
|
|
159
|
+
*/
|
|
160
|
+
getSampleRate(name, options) {
|
|
161
|
+
if (options && isValidSampleRate(options.sampleRate)) {
|
|
162
|
+
return options.sampleRate;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isValidSampleRate(this.sampleRates[name])) {
|
|
166
|
+
return this.sampleRates[name];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this.sampleRate;
|
|
170
|
+
}
|
|
171
|
+
|
|
105
172
|
/**
|
|
106
173
|
* @description Metric shipper function
|
|
107
174
|
* @param {string} name Name of the metric, should be slugified for increased back-end compatibility (e.g. "memory-usage")
|
|
108
175
|
* @param {any} value Value of metric, will be co-erced to an object before being shipped
|
|
176
|
+
* @param {object} [options] Per-call options bag
|
|
177
|
+
* @param {number} [options.sampleRate] Proportion of calls to ship, between 0 and 1, overriding any configured rate
|
|
109
178
|
*/
|
|
110
|
-
metric(name, value) {
|
|
179
|
+
metric(name, value, options) {
|
|
180
|
+
const sampleRate = this.getSampleRate(name, options);
|
|
181
|
+
|
|
182
|
+
// A rate of 1 short-circuits, so unsampled metrics do no extra work
|
|
183
|
+
if (sampleRate < 1 && Math.random() >= sampleRate) {
|
|
184
|
+
return Promise.resolve(null);
|
|
185
|
+
}
|
|
186
|
+
|
|
111
187
|
const promises = [];
|
|
112
188
|
for (const metricShipper of Object.values(this.shippers)) {
|
|
113
|
-
promises.push(metricShipper(name, value));
|
|
189
|
+
promises.push(metricShipper(name, value, sampleRate));
|
|
114
190
|
}
|
|
115
191
|
|
|
116
192
|
return Promise.allSettled(promises).then(() => null);
|
package/lib/metrics.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ declare namespace metrics {
|
|
|
16
16
|
export type GhostMetrics = GhostMetricsClass;
|
|
17
17
|
export type ElasticsearchOptions = GhostMetricsClass.ElasticsearchOptions;
|
|
18
18
|
export type MetricsOptions = GhostMetricsClass.MetricsOptions;
|
|
19
|
+
export type MetricOptions = GhostMetricsClass.MetricOptions;
|
|
19
20
|
export type GhostMetricsOptions = GhostMetricsClass.GhostMetricsOptions;
|
|
20
21
|
export type MetricShipper = GhostMetricsClass.MetricShipper;
|
|
21
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryghost/metrics",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Ghost Foundation",
|
|
6
6
|
"repository": {
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"json-stringify-safe": "5.0.1",
|
|
23
|
-
"@tryghost/
|
|
24
|
-
"@tryghost/
|
|
25
|
-
"@tryghost/root-utils": "^2.3.
|
|
23
|
+
"@tryghost/pretty-stream": "^2.3.9",
|
|
24
|
+
"@tryghost/elasticsearch": "^5.4.6",
|
|
25
|
+
"@tryghost/root-utils": "^2.3.9"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"rewire": "9.0.1",
|