@tryghost/metrics 3.5.4 → 3.6.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 +70 -0
- package/lib/GhostMetrics.d.ts +46 -1
- package/lib/GhostMetrics.js +197 -16
- package/lib/MetricsBatch.d.ts +49 -0
- package/lib/MetricsBatch.js +117 -0
- package/lib/metrics.d.ts +2 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -65,6 +65,75 @@ An invalid configured rate throws when the instance is constructed. An invalid
|
|
|
65
65
|
per-call rate is ignored in favour of the configured rate, so a bad call site
|
|
66
66
|
cannot break the code path it is measuring.
|
|
67
67
|
|
|
68
|
+
### Batching
|
|
69
|
+
|
|
70
|
+
Sampling reduces how many metrics are shipped; batching reduces how many
|
|
71
|
+
requests they take. With batching on, the Elasticsearch transport buffers
|
|
72
|
+
documents and ships them with the bulk API, so a hot code path costs one
|
|
73
|
+
request per batch instead of one per metric. It is off by default:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const metrics = require('@tryghost/metrics');
|
|
77
|
+
|
|
78
|
+
const batched = new metrics.GhostMetrics({
|
|
79
|
+
metrics: {
|
|
80
|
+
transports: ['elasticsearch'],
|
|
81
|
+
batch: true,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`batch: true` uses the defaults; pass an object to override them:
|
|
87
|
+
|
|
88
|
+
| Option | Default | Meaning |
|
|
89
|
+
| --------------- | ----------- | --------------------------------------------------------------- |
|
|
90
|
+
| `enabled` | `true` | Set to `false` to turn batching off without dropping the config |
|
|
91
|
+
| `size` | `100` | Buffered documents that trigger a bulk request |
|
|
92
|
+
| `maxWaitMs` | `5000` | How long a document may sit in the buffer before it is shipped |
|
|
93
|
+
| `maxBufferSize` | `size * 10` | Hard cap on buffered documents; further metrics are dropped |
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
const batched = new metrics.GhostMetrics({
|
|
97
|
+
metrics: {
|
|
98
|
+
transports: ['elasticsearch'],
|
|
99
|
+
batch: {
|
|
100
|
+
size: 500,
|
|
101
|
+
maxWaitMs: 2000,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Documents for different metric names can share a batch, because each bulk
|
|
108
|
+
operation carries its own index. Sampling still applies first, so only metrics
|
|
109
|
+
that survive their sample rate are buffered.
|
|
110
|
+
|
|
111
|
+
Batching changes two things worth knowing about:
|
|
112
|
+
|
|
113
|
+
- `metric()` resolves once the metric is **buffered**, not once it has been
|
|
114
|
+
shipped. It was already fire-and-forget for most callers, but a caller that
|
|
115
|
+
awaited delivery no longer gets it.
|
|
116
|
+
- Buffered metrics are lost if the process exits without draining them. Call
|
|
117
|
+
`flush()` from your shutdown handler; it is a no-op when batching is off, so
|
|
118
|
+
it is always safe to call.
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
process.on('SIGTERM', async () => {
|
|
122
|
+
await batched.flush();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The buffer is bounded: once it holds `maxBufferSize` documents, further metrics
|
|
128
|
+
are dropped rather than growing the heap while Elasticsearch is slow or down.
|
|
129
|
+
Only one bulk request is ever in flight, and a failed batch is dropped without
|
|
130
|
+
rejecting, so a metric call can never break the code path it is measuring.
|
|
131
|
+
|
|
132
|
+
Invalid batch config throws when the instance is constructed.
|
|
133
|
+
|
|
134
|
+
The stdout transport is unaffected - it writes locally, so there is nothing to
|
|
135
|
+
batch.
|
|
136
|
+
|
|
68
137
|
### Types
|
|
69
138
|
|
|
70
139
|
Types ship with the package. `GhostMetrics` is exported as both a value (the
|
|
@@ -77,6 +146,7 @@ import type {
|
|
|
77
146
|
GhostMetricsOptions,
|
|
78
147
|
MetricsOptions,
|
|
79
148
|
MetricOptions,
|
|
149
|
+
BatchOptions,
|
|
80
150
|
ElasticsearchOptions,
|
|
81
151
|
MetricShipper,
|
|
82
152
|
} from '@tryghost/metrics';
|
package/lib/GhostMetrics.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import MetricsBatch = require('./MetricsBatch');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Elasticsearch transport configuration.
|
|
3
5
|
*/
|
|
@@ -12,6 +14,29 @@ interface ElasticsearchOptions {
|
|
|
12
14
|
proxy?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Batch shipping configuration for network transports.
|
|
19
|
+
*/
|
|
20
|
+
interface BatchOptions {
|
|
21
|
+
/** Set to `false` to turn batching off while keeping the rest of the config. @default true */
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
/** Buffered documents that trigger a bulk request. @default 100 */
|
|
24
|
+
size?: number;
|
|
25
|
+
/** How long a document may sit in the buffer before it is shipped, in milliseconds. @default 5000 */
|
|
26
|
+
maxWaitMs?: number;
|
|
27
|
+
/** Hard cap on buffered documents; further metrics are dropped. @default `size * 10` */
|
|
28
|
+
maxBufferSize?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolved batch configuration, with every value filled in.
|
|
33
|
+
*/
|
|
34
|
+
interface ResolvedBatchOptions {
|
|
35
|
+
size: number;
|
|
36
|
+
maxWaitMs: number;
|
|
37
|
+
maxBufferSize: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
/**
|
|
16
41
|
* Metrics-specific configuration, piggy-backed on the logging config.
|
|
17
42
|
*/
|
|
@@ -27,6 +52,12 @@ interface MetricsOptions {
|
|
|
27
52
|
sampleRate?: number;
|
|
28
53
|
/** Per-metric sample rate overrides, keyed by metric name. Same validation as `sampleRate`. */
|
|
29
54
|
sampleRates?: Record<string, number>;
|
|
55
|
+
/**
|
|
56
|
+
* Ship metrics in batches rather than one request per metric. `true` uses the defaults,
|
|
57
|
+
* an object overrides them, absent or `false` keeps one request per metric. Throws at
|
|
58
|
+
* construction time for values that aren't positive whole numbers.
|
|
59
|
+
*/
|
|
60
|
+
batch?: boolean | BatchOptions;
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
/**
|
|
@@ -71,7 +102,11 @@ declare class GhostMetrics {
|
|
|
71
102
|
metadata: Record<string, unknown>;
|
|
72
103
|
sampleRate: number;
|
|
73
104
|
sampleRates: Record<string, number>;
|
|
105
|
+
/** Resolved batch config, or `null` when batching is off. */
|
|
106
|
+
batch: ResolvedBatchOptions | null;
|
|
74
107
|
shippers: Record<string, MetricShipper>;
|
|
108
|
+
/** Buffers belonging to batching transports, drained by {@link GhostMetrics.flush}. */
|
|
109
|
+
batches: MetricsBatch[];
|
|
75
110
|
|
|
76
111
|
constructor(options?: GhostMetricsOptions);
|
|
77
112
|
|
|
@@ -82,10 +117,16 @@ declare class GhostMetrics {
|
|
|
82
117
|
|
|
83
118
|
/**
|
|
84
119
|
* Setup ElasticSearch metric shipper. Metrics are shipped to a per-metric
|
|
85
|
-
* index named `metrics-<name>`; the metric name should be sluggified.
|
|
120
|
+
* index named `metrics-<name>`; the metric name should be sluggified. With
|
|
121
|
+
* batching on, documents are buffered and shipped with the bulk API.
|
|
86
122
|
*/
|
|
87
123
|
setupElasticsearchShipper(): void;
|
|
88
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Ship anything buffered by batching transports. A no-op when batching is off.
|
|
127
|
+
*/
|
|
128
|
+
flush(): Promise<void>;
|
|
129
|
+
|
|
89
130
|
/**
|
|
90
131
|
* Resolve the sample rate for a metric: per-call override, then per-metric config,
|
|
91
132
|
* then the instance default.
|
|
@@ -94,6 +135,8 @@ declare class GhostMetrics {
|
|
|
94
135
|
|
|
95
136
|
/**
|
|
96
137
|
* Ship a metric through every configured transport, unless it is dropped by sampling.
|
|
138
|
+
* With batching on, the returned promise resolves once the metric is buffered rather
|
|
139
|
+
* than once it has been shipped.
|
|
97
140
|
* @param name Metric name, should be slugified for back-end compatibility (e.g. `"memory-usage"`).
|
|
98
141
|
* @param value Metric value; coerced to an object before being shipped.
|
|
99
142
|
* @param options Per-call options, e.g. a `sampleRate` override.
|
|
@@ -106,6 +149,8 @@ declare namespace GhostMetrics {
|
|
|
106
149
|
ElasticsearchOptions,
|
|
107
150
|
MetricsOptions,
|
|
108
151
|
MetricOptions,
|
|
152
|
+
BatchOptions,
|
|
153
|
+
ResolvedBatchOptions,
|
|
109
154
|
GhostMetricsOptions,
|
|
110
155
|
MetricShipper,
|
|
111
156
|
};
|
package/lib/GhostMetrics.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
const jsonStringifySafe = require('json-stringify-safe');
|
|
2
|
+
const MetricsBatch = require('./MetricsBatch');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
5
|
+
const DEFAULT_BATCH_MAX_WAIT_MS = 5000;
|
|
6
|
+
// Ten batches' worth of headroom before the buffer starts shedding load
|
|
7
|
+
const DEFAULT_BATCH_BUFFER_FACTOR = 10;
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* @description Check a value is usable as a sample rate: a number between 0 and 1 inclusive
|
|
@@ -27,6 +33,156 @@ function normalizeSampleRate(rate, key) {
|
|
|
27
33
|
return rate;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @description Validate a batching config value that must be a positive whole number
|
|
38
|
+
* @param {any} value Candidate value
|
|
39
|
+
* @param {number} fallback Value to use when unset
|
|
40
|
+
* @param {string} key Config key used in the error message
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
function normalizePositiveInteger(value, fallback, key) {
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
49
|
+
throw new Error(`${key} must be a positive whole number, got ${jsonStringifySafe(value)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @description Validate the batching config, returning null when batching is off
|
|
57
|
+
* @param {any} batch Candidate batch config: absent/false for off, true or a property bag for on
|
|
58
|
+
* @returns {{size: number, maxWaitMs: number, maxBufferSize: number} | null}
|
|
59
|
+
*/
|
|
60
|
+
function normalizeBatch(batch) {
|
|
61
|
+
if (batch === undefined || batch === null || batch === false) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (batch === true) {
|
|
66
|
+
batch = {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof batch !== 'object' || Array.isArray(batch)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`metrics.batch must be a boolean or an object, got ${jsonStringifySafe(batch)}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `enabled` lets a deployment turn batching off without discarding the rest
|
|
76
|
+
// of the config, which matters when it is layered from config files
|
|
77
|
+
if ('enabled' in batch && typeof batch.enabled !== 'boolean') {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`metrics.batch.enabled must be a boolean, got ${jsonStringifySafe(batch.enabled)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (batch.enabled === false) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const size = normalizePositiveInteger(batch.size, DEFAULT_BATCH_SIZE, 'metrics.batch.size');
|
|
88
|
+
const maxWaitMs = normalizePositiveInteger(
|
|
89
|
+
batch.maxWaitMs,
|
|
90
|
+
DEFAULT_BATCH_MAX_WAIT_MS,
|
|
91
|
+
'metrics.batch.maxWaitMs',
|
|
92
|
+
);
|
|
93
|
+
const maxBufferSize = normalizePositiveInteger(
|
|
94
|
+
batch.maxBufferSize,
|
|
95
|
+
size * DEFAULT_BATCH_BUFFER_FACTOR,
|
|
96
|
+
'metrics.batch.maxBufferSize',
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (maxBufferSize < size) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`metrics.batch.maxBufferSize must be at least metrics.batch.size (${size}), got ${maxBufferSize}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { size, maxWaitMs, maxBufferSize };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @description Clone structured metric data without allowing unusual values
|
|
110
|
+
* to make instrumentation throw.
|
|
111
|
+
* @param {any} value Value to clone
|
|
112
|
+
* @returns {any}
|
|
113
|
+
*/
|
|
114
|
+
function cloneMetricValueFallback(value, seen = new WeakMap()) {
|
|
115
|
+
if (typeof value !== 'object' || value === null) {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (seen.has(value)) {
|
|
120
|
+
return seen.get(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const prototype = Object.getPrototypeOf(value);
|
|
124
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const clone = Array.isArray(value) ? [] : Object.create(prototype);
|
|
129
|
+
seen.set(value, clone);
|
|
130
|
+
|
|
131
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
132
|
+
clone[key] = cloneMetricValueFallback(nestedValue, seen);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return clone;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cloneMetricValue(value) {
|
|
139
|
+
if (typeof value !== 'object' || value === null) {
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
return structuredClone(value);
|
|
145
|
+
} catch {
|
|
146
|
+
// Keep instrumentation from throwing for unusual non-cloneable values.
|
|
147
|
+
return cloneMetricValueFallback(value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @description Build the document shipped for a metric value
|
|
153
|
+
* The value is copied rather than mutated, so a batched document can't be
|
|
154
|
+
* changed by the caller after it has been buffered.
|
|
155
|
+
* @param {any} value Value of the metric
|
|
156
|
+
* @param {object} metadata Metadata to ship alongside the value
|
|
157
|
+
* @param {number} sampleRate Rate the metric survived
|
|
158
|
+
* @returns {object}
|
|
159
|
+
*/
|
|
160
|
+
function buildDocument(value, metadata, sampleRate) {
|
|
161
|
+
// Metrics are expected to be structured data. Clone them synchronously so
|
|
162
|
+
// callers can safely reuse or mutate nested values after `metric()` returns.
|
|
163
|
+
const clonedValue = cloneMetricValue(value);
|
|
164
|
+
const document =
|
|
165
|
+
typeof clonedValue === 'object' && clonedValue !== null && !Array.isArray(clonedValue)
|
|
166
|
+
? clonedValue
|
|
167
|
+
: { value: clonedValue };
|
|
168
|
+
|
|
169
|
+
if (!('@timestamp' in document)) {
|
|
170
|
+
document['@timestamp'] = Date.now();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (metadata) {
|
|
174
|
+
document.metadata = cloneMetricValue(metadata);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sampled documents carry their rate so consumers can scale counts back up.
|
|
178
|
+
// An absent sampleRate means the metric was not sampled (i.e. a rate of 1).
|
|
179
|
+
if (sampleRate < 1) {
|
|
180
|
+
document.sampleRate = sampleRate;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return document;
|
|
184
|
+
}
|
|
185
|
+
|
|
30
186
|
/**
|
|
31
187
|
* @description Metric shipper class built on the loggingrc config used in Ghost projects
|
|
32
188
|
*/
|
|
@@ -40,6 +196,7 @@ class GhostMetrics {
|
|
|
40
196
|
* metrics.metadata: A property bag of metadata values to be shipped alongside the metric value
|
|
41
197
|
* metrics.sampleRate: Default proportion of metrics to ship, between 0 and 1 (defaults to 1, ship everything)
|
|
42
198
|
* metrics.sampleRates: Per-metric sample rate overrides, keyed by metric name
|
|
199
|
+
* metrics.batch: Batch shipping config for network transports; absent or false to ship one metric per request
|
|
43
200
|
* elasticsearch: Elasticsearch transport configuration
|
|
44
201
|
* @param {object} options Bag of options
|
|
45
202
|
*/
|
|
@@ -56,6 +213,7 @@ class GhostMetrics {
|
|
|
56
213
|
this.transports = options.metrics.transports || [];
|
|
57
214
|
this.metadata = options.metrics.metadata || {};
|
|
58
215
|
this.sampleRate = normalizeSampleRate(options.metrics.sampleRate, 'metrics.sampleRate');
|
|
216
|
+
this.batch = normalizeBatch(options.metrics.batch);
|
|
59
217
|
|
|
60
218
|
for (const [name, rate] of Object.entries(options.metrics.sampleRates || {})) {
|
|
61
219
|
this.sampleRates[name] = normalizeSampleRate(rate, `metrics.sampleRates.${name}`);
|
|
@@ -64,6 +222,7 @@ class GhostMetrics {
|
|
|
64
222
|
this.transports = [];
|
|
65
223
|
this.metadata = {};
|
|
66
224
|
this.sampleRate = 1;
|
|
225
|
+
this.batch = null;
|
|
67
226
|
}
|
|
68
227
|
|
|
69
228
|
// CASE: special env variable to enable long mode and level info
|
|
@@ -72,6 +231,8 @@ class GhostMetrics {
|
|
|
72
231
|
}
|
|
73
232
|
|
|
74
233
|
this.shippers = {};
|
|
234
|
+
// Populated by transports that buffer, so `flush()` can drain them
|
|
235
|
+
this.batches = [];
|
|
75
236
|
|
|
76
237
|
this.transports.forEach((transport) => {
|
|
77
238
|
let transportFn = `setup${transport[0].toUpperCase()}${transport.substr(1)}Shipper`;
|
|
@@ -126,29 +287,49 @@ class GhostMetrics {
|
|
|
126
287
|
proxy: 'proxy' in this.elasticsearch ? this.elasticsearch.proxy : null,
|
|
127
288
|
});
|
|
128
289
|
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
290
|
+
if (!this.batch) {
|
|
291
|
+
this.shippers.elasticsearch = (name, value, sampleRate) => {
|
|
292
|
+
return elasticSearch.index(
|
|
293
|
+
buildDocument(value, this.metadata, sampleRate),
|
|
294
|
+
`metrics-${name}`,
|
|
295
|
+
);
|
|
296
|
+
};
|
|
133
297
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
137
300
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
301
|
+
// Batched metrics are shipped with the bulk API, so a hot code path
|
|
302
|
+
// costs one request per batch instead of one per metric. Documents for
|
|
303
|
+
// different metric names can share a batch because each operation
|
|
304
|
+
// carries its own index.
|
|
305
|
+
const batch = new MetricsBatch({
|
|
306
|
+
...this.batch,
|
|
307
|
+
ship: (operations) => elasticSearch.bulk(operations),
|
|
308
|
+
});
|
|
141
309
|
|
|
142
|
-
|
|
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
|
-
}
|
|
310
|
+
this.batches.push(batch);
|
|
147
311
|
|
|
148
|
-
|
|
312
|
+
this.shippers.elasticsearch = (name, value, sampleRate) => {
|
|
313
|
+
batch.add({
|
|
314
|
+
index: `metrics-${name}`,
|
|
315
|
+
document: buildDocument(value, this.metadata, sampleRate),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Resolves once the metric is buffered, not once it is shipped
|
|
319
|
+
return Promise.resolve();
|
|
149
320
|
};
|
|
150
321
|
}
|
|
151
322
|
|
|
323
|
+
/**
|
|
324
|
+
* @description Ship anything buffered by batching transports
|
|
325
|
+
* A no-op when batching is off, so callers (e.g. a shutdown handler) can
|
|
326
|
+
* always call it without knowing how the instance is configured.
|
|
327
|
+
* @returns {Promise<void>}
|
|
328
|
+
*/
|
|
329
|
+
async flush() {
|
|
330
|
+
await Promise.allSettled(this.batches.map((batch) => batch.flush()));
|
|
331
|
+
}
|
|
332
|
+
|
|
152
333
|
/**
|
|
153
334
|
* @description Resolve the sample rate for a metric, most specific config first
|
|
154
335
|
* Invalid per-call overrides are ignored rather than thrown, so a bad call site cannot
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffers shipping operations and hands them off in batches.
|
|
3
|
+
*
|
|
4
|
+
* Batching trades delivery latency for a much smaller number of requests.
|
|
5
|
+
* Buffered operations are handed off when the buffer reaches `size`, when
|
|
6
|
+
* `maxWaitMs` has elapsed since the first buffered operation, or when
|
|
7
|
+
* {@link MetricsBatch.flush} is called.
|
|
8
|
+
*/
|
|
9
|
+
declare class MetricsBatch<T = unknown> {
|
|
10
|
+
/** Number of buffered operations that triggers a hand-off. */
|
|
11
|
+
size: number;
|
|
12
|
+
/** How long an operation may sit in the buffer before it is handed off, in milliseconds. */
|
|
13
|
+
maxWaitMs: number;
|
|
14
|
+
/** Hard cap on buffered operations; further operations are dropped. */
|
|
15
|
+
maxBufferSize: number;
|
|
16
|
+
/** Hands a batch of operations to the transport. */
|
|
17
|
+
ship: (operations: T[]) => Promise<unknown>;
|
|
18
|
+
/** Operations waiting to be shipped. */
|
|
19
|
+
buffer: T[];
|
|
20
|
+
/** Count of operations dropped because the buffer was full. */
|
|
21
|
+
dropped: number;
|
|
22
|
+
|
|
23
|
+
constructor(options: {
|
|
24
|
+
size: number;
|
|
25
|
+
maxWaitMs: number;
|
|
26
|
+
maxBufferSize: number;
|
|
27
|
+
ship: (operations: T[]) => Promise<unknown>;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Buffer an operation, handing the buffer off if it is now full. Operations
|
|
32
|
+
* arriving once the buffer is at `maxBufferSize` are dropped.
|
|
33
|
+
*/
|
|
34
|
+
add(operation: T): void;
|
|
35
|
+
|
|
36
|
+
/** Start the max-wait timer, unless one is already running. */
|
|
37
|
+
startTimer(): void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ship everything buffered right now, waiting behind any in-flight batch.
|
|
41
|
+
* Never rejects.
|
|
42
|
+
*/
|
|
43
|
+
flush(): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/** Hand the whole buffer to the transport. Never rejects. */
|
|
46
|
+
drain(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export = MetricsBatch;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Buffers shipping operations and hands them off in batches
|
|
3
|
+
*
|
|
4
|
+
* Batching trades delivery latency for a much smaller number of requests: the
|
|
5
|
+
* caller gets one round-trip per batch instead of one per metric. Buffered
|
|
6
|
+
* operations are handed off when the buffer reaches `size`, when `maxWaitMs`
|
|
7
|
+
* has elapsed since the first buffered operation, or when `flush()` is called.
|
|
8
|
+
*/
|
|
9
|
+
class MetricsBatch {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options Bag of options
|
|
12
|
+
* @param {number} options.size Number of buffered operations that triggers a hand-off
|
|
13
|
+
* @param {number} options.maxWaitMs How long an operation may sit in the buffer before it is handed off
|
|
14
|
+
* @param {number} options.maxBufferSize Hard cap on buffered operations; further operations are dropped
|
|
15
|
+
* @param {(operations: any[]) => Promise<any>} options.ship Hands a batch of operations to the transport
|
|
16
|
+
*/
|
|
17
|
+
constructor({ size, maxWaitMs, maxBufferSize, ship }) {
|
|
18
|
+
this.size = size;
|
|
19
|
+
this.maxWaitMs = maxWaitMs;
|
|
20
|
+
this.maxBufferSize = maxBufferSize;
|
|
21
|
+
this.ship = ship;
|
|
22
|
+
|
|
23
|
+
this.buffer = [];
|
|
24
|
+
this.timer = null;
|
|
25
|
+
// Serialises hand-offs, so only one batch is ever in flight
|
|
26
|
+
this.pending = Promise.resolve();
|
|
27
|
+
// A non-zero count means the transport couldn't keep up and metrics
|
|
28
|
+
// were lost; read it off the instance when metrics go missing
|
|
29
|
+
this.dropped = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @description Buffer an operation, handing the buffer off if it is now full
|
|
34
|
+
* @param {any} operation Operation to ship
|
|
35
|
+
*/
|
|
36
|
+
add(operation) {
|
|
37
|
+
if (this.buffer.length >= this.maxBufferSize) {
|
|
38
|
+
// Shed load rather than growing without bound when the transport is
|
|
39
|
+
// slow or down: losing metrics beats exhausting the heap
|
|
40
|
+
this.dropped += 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.buffer.push(operation);
|
|
45
|
+
|
|
46
|
+
// Queue exactly once when the buffer crosses the threshold. `drain()`
|
|
47
|
+
// resets the buffer before awaiting the transport, so the next buffer
|
|
48
|
+
// can independently cross the threshold and line up behind it.
|
|
49
|
+
if (this.buffer.length === this.size) {
|
|
50
|
+
this.flush();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.startTimer();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @description Start the max-wait timer, unless one is already running
|
|
59
|
+
* The timer is unreferenced so a partially full buffer can never hold the
|
|
60
|
+
* event loop - and therefore the process - open.
|
|
61
|
+
*/
|
|
62
|
+
startTimer() {
|
|
63
|
+
if (this.timer) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.timer = setTimeout(() => {
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.flush();
|
|
70
|
+
}, this.maxWaitMs);
|
|
71
|
+
|
|
72
|
+
this.timer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @description Ship everything buffered right now
|
|
77
|
+
* Waits behind any in-flight batch, so batches reach the transport in
|
|
78
|
+
* order and only one request is open at a time.
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
flush() {
|
|
82
|
+
if (this.timer) {
|
|
83
|
+
clearTimeout(this.timer);
|
|
84
|
+
this.timer = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const previous = this.pending;
|
|
88
|
+
|
|
89
|
+
this.pending = previous.then(() => this.drain());
|
|
90
|
+
|
|
91
|
+
return this.pending;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @description Hand the whole buffer to the transport
|
|
96
|
+
* Never rejects, so a failed batch can't poison the hand-off chain or the
|
|
97
|
+
* code path being measured. Transports report their own failures - the
|
|
98
|
+
* Elasticsearch one logs them under the `logging:elasticsearch` debug namespace.
|
|
99
|
+
* @returns {Promise<void>}
|
|
100
|
+
*/
|
|
101
|
+
async drain() {
|
|
102
|
+
if (this.buffer.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const operations = this.buffer;
|
|
107
|
+
this.buffer = [];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await this.ship(operations);
|
|
111
|
+
} catch {
|
|
112
|
+
// Swallowed: a metric call can never break the path it measures
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = MetricsBatch;
|
package/lib/metrics.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ declare namespace metrics {
|
|
|
17
17
|
export type ElasticsearchOptions = GhostMetricsClass.ElasticsearchOptions;
|
|
18
18
|
export type MetricsOptions = GhostMetricsClass.MetricsOptions;
|
|
19
19
|
export type MetricOptions = GhostMetricsClass.MetricOptions;
|
|
20
|
+
export type BatchOptions = GhostMetricsClass.BatchOptions;
|
|
21
|
+
export type ResolvedBatchOptions = GhostMetricsClass.ResolvedBatchOptions;
|
|
20
22
|
export type GhostMetricsOptions = GhostMetricsClass.GhostMetricsOptions;
|
|
21
23
|
export type MetricShipper = GhostMetricsClass.MetricShipper;
|
|
22
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryghost/metrics",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Ghost Foundation",
|
|
6
6
|
"repository": {
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@tryghost/elasticsearch": "^5.4.
|
|
23
|
-
"@tryghost/pretty-stream": "^2.3.
|
|
24
|
-
"@tryghost/root-utils": "^2.3.
|
|
22
|
+
"@tryghost/elasticsearch": "^5.4.11",
|
|
23
|
+
"@tryghost/pretty-stream": "^2.3.14",
|
|
24
|
+
"@tryghost/root-utils": "^2.3.14",
|
|
25
25
|
"json-stringify-safe": "^5.0.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|