@tryghost/metrics 3.4.2 → 3.5.1

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 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';
@@ -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
- * Ship a metric through every configured transport.
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 { ElasticsearchOptions, MetricsOptions, GhostMetricsOptions, MetricShipper };
105
+ export {
106
+ ElasticsearchOptions,
107
+ MetricsOptions,
108
+ MetricOptions,
109
+ GhostMetricsOptions,
110
+ MetricShipper,
111
+ };
78
112
  }
79
113
 
80
114
  export = GhostMetrics;
@@ -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: An array of transports for metric shipping (e.g. ['stdout', 'elasticsearch'])
13
- * metrics.metadata: A property bag of metadata values to be shipped alongside the metric value
14
- * elasticsearch: Elasticsearch transport configuration
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
- if ('metrics' in options && typeof options.metrics === 'object') {
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.4.2",
3
+ "version": "3.5.1",
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/elasticsearch": "^5.4.5",
24
- "@tryghost/pretty-stream": "^2.3.8",
25
- "@tryghost/root-utils": "^2.3.8"
23
+ "@tryghost/elasticsearch": "^5.4.7",
24
+ "@tryghost/pretty-stream": "^2.3.10",
25
+ "@tryghost/root-utils": "^2.3.10"
26
26
  },
27
27
  "devDependencies": {
28
28
  "rewire": "9.0.1",