@wardx/core 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.
- package/README.md +289 -0
- package/defaults.json +14 -0
- package/package.json +19 -0
- package/src/WardxCore.js +198 -0
- package/src/buffers/EventBuffer.js +28 -0
- package/src/buffers/LogBuffer.js +48 -0
- package/src/config/ConfigStore.js +31 -0
- package/src/config/ExperimentResolver.js +118 -0
- package/src/config/hash.js +27 -0
- package/src/frame/FrameBuilder.js +132 -0
- package/src/ids.js +30 -0
- package/src/index.js +14 -0
- package/src/internal/InternalMetrics.js +53 -0
- package/src/metrics/Counter.js +23 -0
- package/src/metrics/Gauge.js +22 -0
- package/src/metrics/Histogram.js +85 -0
- package/src/metrics/MetricsRegistry.js +172 -0
- package/src/metrics/Timer.js +6 -0
- package/src/metrics/dimensions.js +42 -0
- package/src/protocol.js +53 -0
- package/src/settings.js +97 -0
- package/src/trace/emit.js +5 -0
- package/src/trace/wrap.js +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# @wardx/core
|
|
2
|
+
|
|
3
|
+
`@wardx/core` is the runtime-agnostic engine of Wardx.
|
|
4
|
+
|
|
5
|
+
The engine records metrics, events, and logs in memory. The engine also stores Remote Config and assigns experiment variants.
|
|
6
|
+
|
|
7
|
+
The engine does not send HTTP. A runtime package, for example `wardx`, sends the frames.
|
|
8
|
+
|
|
9
|
+
Node.js 20 or later is required.
|
|
10
|
+
|
|
11
|
+
Install this package from npm when you write a custom runtime. If you use Node.js, install `wardx`. The `wardx` package depends on `@wardx/core`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @wardx/core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { WardxCore, assignVariant, loadSdkDefaults } from '@wardx/core';
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Design rules
|
|
24
|
+
|
|
25
|
+
- A measure call changes local memory only.
|
|
26
|
+
- A measure call does not send network data.
|
|
27
|
+
- A measure call does not wait for a Promise.
|
|
28
|
+
- If a buffer is full, the engine discards data. The engine does not block the application.
|
|
29
|
+
- Counters in a frame are window deltas. Counters are not lifetime totals.
|
|
30
|
+
|
|
31
|
+
## Settings
|
|
32
|
+
|
|
33
|
+
`WardxCore` needs a settings object. Use `loadSdkDefaults()` and add the identity fields.
|
|
34
|
+
|
|
35
|
+
| Key | Description |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `endpoint` | Sync URL. The core does not use this key. Runtimes use this key. |
|
|
38
|
+
| `projectKey` | Project credential. Runtimes send this key. If `privacySalt` is empty, the core uses this key as the salt. |
|
|
39
|
+
| `project` | Project name. |
|
|
40
|
+
| `role` | Runtime identity inside the project. Runtimes send this name. The core does not use this key. |
|
|
41
|
+
| `appVersion` | Application version. |
|
|
42
|
+
| `environment` | Environment name, for example `production`. |
|
|
43
|
+
| `privacySalt` | Salt for the hashed subject. If you omit this key, the core uses `projectKey`. |
|
|
44
|
+
| `aggregateIntervalMs` | Default `1000`. Interval to snapshot dirty data. |
|
|
45
|
+
| `syncIntervalMs` | Default `15000`. Interval for the runtime sync. |
|
|
46
|
+
| `maxBufferedEvents` | Default `5000`. |
|
|
47
|
+
| `maxBufferedLogs` | Default `2000`. |
|
|
48
|
+
| `maxFrameBytes` | Default `524288`. |
|
|
49
|
+
| `maxSeriesPerMetric` | Default `1000`. |
|
|
50
|
+
| `maxDimensionKeys` | Default `8`. |
|
|
51
|
+
| `maxDimensionValueLength` | Default `64`. |
|
|
52
|
+
| `histogramBuckets` | Default `[10, 25, 50, 100, 250, 500, 1000]`. |
|
|
53
|
+
| `tracer` | Optional. Duck-typed local hook with any of `measure`, `event`, `log`, `frame`. The core does not print. Runtimes may also call `sync`. |
|
|
54
|
+
|
|
55
|
+
The defaults live in `defaults.json`. Do not omit a required key. The loader does not add a fallback for a missing key. `tracer` is not a default key. Omit it to keep the measure path unchanged.
|
|
56
|
+
|
|
57
|
+
## Use case 1: Record metrics in a custom runtime
|
|
58
|
+
|
|
59
|
+
**When:** You write a runtime that is not Node.js, or you test the engine without HTTP.
|
|
60
|
+
|
|
61
|
+
**Objective:** Record counters, gauges, histograms, and timers. Then make a frame.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { WardxCore, loadSdkDefaults } from '@wardx/core';
|
|
65
|
+
|
|
66
|
+
const settings = {
|
|
67
|
+
...loadSdkDefaults(),
|
|
68
|
+
endpoint: 'http://127.0.0.1:8787',
|
|
69
|
+
projectKey: 'dev_project_key',
|
|
70
|
+
project: 'demo',
|
|
71
|
+
role: 'client',
|
|
72
|
+
appVersion: '0.1.0',
|
|
73
|
+
environment: 'development',
|
|
74
|
+
privacySalt: 'dev_project_key'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const core = new WardxCore(settings);
|
|
78
|
+
|
|
79
|
+
core.counter('match.completed', { mode: 'ranked' }).inc();
|
|
80
|
+
core.counter('coins.awarded').add(25);
|
|
81
|
+
core.gauge('players.online').set(12921);
|
|
82
|
+
core.histogram('request.duration').observe(42);
|
|
83
|
+
|
|
84
|
+
const endTimer = core.timer('matchmaking.duration');
|
|
85
|
+
endTimer({ result: 'success' });
|
|
86
|
+
|
|
87
|
+
const fitted = core.snapshotFrame();
|
|
88
|
+
const frames = core.takePendingFrames();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Procedure
|
|
92
|
+
|
|
93
|
+
1. Load the SDK defaults.
|
|
94
|
+
2. Add the identity fields.
|
|
95
|
+
3. Construct `WardxCore`.
|
|
96
|
+
4. Call `counter`, `gauge`, `histogram`, or `timer`.
|
|
97
|
+
5. Call `snapshotFrame` when you need a frame.
|
|
98
|
+
6. Call `takePendingFrames` to get the pending frames.
|
|
99
|
+
|
|
100
|
+
`counter(name, dims).inc()` adds `1`. `add(n)` adds a finite number `n`.
|
|
101
|
+
|
|
102
|
+
`gauge(name, dims).set(value)` stores the last finite value and a timestamp.
|
|
103
|
+
|
|
104
|
+
`histogram(name).observe(value)` records a finite value into buckets. You can set buckets:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
core.histogram('request.duration', { buckets: [10, 25, 50, 100] }).observe(42);
|
|
108
|
+
core.histogram('coins.award_size').observe(80, { grantId: 'g-80' });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`observe(value, attrs)` keeps `attrs` only when `value` is the window max. The frame stores that pair as `exemplar`. Attrs use the same key and value limits as dimensions. Do not change the buckets of an existing series. The engine throws an error.
|
|
112
|
+
|
|
113
|
+
`timer(name, dims)` starts a timer. The returned function records the duration in milliseconds into a histogram. You can add dimensions when you stop the timer.
|
|
114
|
+
|
|
115
|
+
If a series is above `maxSeriesPerMetric`, or a dimension is not valid, the engine returns a no-op object. The engine increments `wardx.internal.cardinality_dropped`.
|
|
116
|
+
|
|
117
|
+
A dimension value must be a string, a number, or a boolean.
|
|
118
|
+
|
|
119
|
+
## Use case 2: Record product events and logs
|
|
120
|
+
|
|
121
|
+
**When:** You need discrete product events or structured logs in the same frame as metrics.
|
|
122
|
+
|
|
123
|
+
**Objective:** Buffer events and logs until the next snapshot.
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
core.event('purchase', { product: 'premium' });
|
|
127
|
+
core.log.info('match_started', { mode: 'ranked', players: 4 });
|
|
128
|
+
core.log.error('payment_failed', { code: 'timeout' });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Log levels: `debug`, `info`, `warn`, `error`.
|
|
132
|
+
|
|
133
|
+
### Buffer limits
|
|
134
|
+
|
|
135
|
+
- If the event buffer is full, the engine discards the new event.
|
|
136
|
+
- If the log buffer is full, the engine replaces a log with a lower severity when possible.
|
|
137
|
+
- If the engine cannot replace a log, the engine discards the new log.
|
|
138
|
+
|
|
139
|
+
Dropped items increment `wardx.internal.events_dropped` or `wardx.internal.logs_dropped`.
|
|
140
|
+
|
|
141
|
+
Use counters and histograms for rates and latency. Use events for rare product facts: a purchase, an experiment exposure or goal, a named screen. Use logs for failures. Do not put user ids on metric dimensions.
|
|
142
|
+
|
|
143
|
+
## Use case 3: Get Remote Config and assign an experiment
|
|
144
|
+
|
|
145
|
+
**When:** The server sends a config snapshot. You need a value for a subject.
|
|
146
|
+
|
|
147
|
+
**Objective:** Get a config value. If an experiment applies, get the variant value.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
core.applyConfig(13, {
|
|
151
|
+
values: {
|
|
152
|
+
'message.delayMs': 1000,
|
|
153
|
+
'chat.enabled': true
|
|
154
|
+
},
|
|
155
|
+
experiments: [
|
|
156
|
+
{
|
|
157
|
+
id: 'message-delay-v1',
|
|
158
|
+
enabled: true,
|
|
159
|
+
allocation: 1,
|
|
160
|
+
salt: '3ad8f9',
|
|
161
|
+
primaryMetric: 'message.sent',
|
|
162
|
+
variants: [
|
|
163
|
+
{ key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
|
|
164
|
+
{ key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const fallback = 1000;
|
|
171
|
+
const delay = core.configGet('message.delayMs', fallback, { subjectId: 'user-1' });
|
|
172
|
+
core.experimentGoal('message.sent', { subjectId: 'user-1', value: 1 });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Resolution order
|
|
176
|
+
|
|
177
|
+
1. If the key is not in the snapshot, return `fallback`.
|
|
178
|
+
2. If `subjectId` is missing, return the Remote Config value.
|
|
179
|
+
3. If no enabled experiment contains the key, return the Remote Config value.
|
|
180
|
+
4. If the subject is not in the allocation, return the Remote Config value.
|
|
181
|
+
5. If the subject is in the allocation, return the variant value.
|
|
182
|
+
|
|
183
|
+
The assignment is deterministic. The same `experimentId`, `subjectId`, and `salt` always give the same variant.
|
|
184
|
+
|
|
185
|
+
The first resolve for a subject in a session emits event `experiment.exposure`. The payload contains a hashed subject. The payload does not contain the raw `subjectId`.
|
|
186
|
+
|
|
187
|
+
`experimentGoal` emits event `experiment.goal`. You must supply `subjectId`. You can supply `value`.
|
|
188
|
+
|
|
189
|
+
## Use case 4: Assign a variant without WardxCore
|
|
190
|
+
|
|
191
|
+
**When:** You verify experiment math, or you assign a variant in a test.
|
|
192
|
+
|
|
193
|
+
**Objective:** Use the same FNV-1a 32-bit function as the SDK.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { assignVariant, assignmentHash, hashToUnitInterval, subjectHash } from '@wardx/core';
|
|
197
|
+
|
|
198
|
+
const experiment = {
|
|
199
|
+
id: 'message-delay-v1',
|
|
200
|
+
enabled: true,
|
|
201
|
+
allocation: 0.5,
|
|
202
|
+
salt: '3ad8f9',
|
|
203
|
+
variants: [
|
|
204
|
+
{ key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
|
|
205
|
+
{ key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
|
|
206
|
+
]
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const variant = assignVariant(experiment, 'user-1');
|
|
210
|
+
const hash = assignmentHash(experiment.id, 'user-1', experiment.salt);
|
|
211
|
+
const bucket = hashToUnitInterval(hash);
|
|
212
|
+
const hashedSubject = subjectHash('dev_project_key', 'user-1');
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Hash input:
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
hash = fnv1a32(experimentId + ':' + subjectId + ':' + salt)
|
|
219
|
+
bucket = hash / 2^32
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
223
|
+
|
|
224
|
+
`subjectHash` returns 8 lowercase hex digits of `fnv1a32(privacySalt + ':' + subjectId)`.
|
|
225
|
+
|
|
226
|
+
## Use case 5: Build a frame for a custom transport
|
|
227
|
+
|
|
228
|
+
**When:** You send frames with your transport. You do not use the Node SDK.
|
|
229
|
+
|
|
230
|
+
**Objective:** Snapshot dirty data, then take the pending frames.
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
import { FrameBuilder, PROTOCOL_VERSION, SDK_NAME, PLATFORM } from '@wardx/core';
|
|
234
|
+
|
|
235
|
+
core.counter('match.completed').inc();
|
|
236
|
+
const fitted = core.snapshotIfDirty();
|
|
237
|
+
if (fitted) {
|
|
238
|
+
const frames = core.takePendingFrames();
|
|
239
|
+
const envelope = {
|
|
240
|
+
protocol: PROTOCOL_VERSION,
|
|
241
|
+
project: settings.project,
|
|
242
|
+
sdk: { name: SDK_NAME, version: '0.1.0' },
|
|
243
|
+
client: {
|
|
244
|
+
instanceId: '01…',
|
|
245
|
+
sessionId: '01…',
|
|
246
|
+
role: settings.role,
|
|
247
|
+
appVersion: settings.appVersion,
|
|
248
|
+
environment: settings.environment,
|
|
249
|
+
platform: PLATFORM
|
|
250
|
+
},
|
|
251
|
+
configVersion: core.configStore.version,
|
|
252
|
+
frames
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`snapshotIfDirty` returns `null` when there is no new data.
|
|
258
|
+
|
|
259
|
+
If a frame is larger than `maxFrameBytes`, `FrameBuilder.fitToMaxBytes` discards data in this order:
|
|
260
|
+
|
|
261
|
+
1. Logs with the lowest severity.
|
|
262
|
+
2. Events from the end of the buffer.
|
|
263
|
+
3. Application histograms.
|
|
264
|
+
4. Application gauges. Internal gauges stay.
|
|
265
|
+
|
|
266
|
+
Internal series use the prefix `wardx.internal.`.
|
|
267
|
+
|
|
268
|
+
## Exports
|
|
269
|
+
|
|
270
|
+
| Export | Function |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| `WardxCore` | Engine. |
|
|
273
|
+
| `ConfigStore` | Stores one Remote Config snapshot. |
|
|
274
|
+
| `ExperimentResolver` | Assigns variants and records exposure. |
|
|
275
|
+
| `assignVariant` | Assigns one variant. |
|
|
276
|
+
| `fnv1a32`, `assignmentHash`, `hashToUnitInterval`, `subjectHash` | Hash helpers. |
|
|
277
|
+
| `Counter`, `Gauge`, `Histogram`, `MetricsRegistry` | Metric types. |
|
|
278
|
+
| `EventBuffer`, `LogBuffer` | In-memory buffers. |
|
|
279
|
+
| `FrameBuilder` | Builds and trims frames. |
|
|
280
|
+
| `resolveSettings`, `loadSdkDefaults`, `nextSyncDelayMs` | Settings helpers. |
|
|
281
|
+
| `PROTOCOL_VERSION`, `SDK_NAME`, `PLATFORM`, `INTERNAL` | Protocol constants. |
|
|
282
|
+
| `ulid` | Identifier helper. |
|
|
283
|
+
|
|
284
|
+
## Related packages
|
|
285
|
+
|
|
286
|
+
- Node.js SDK: `wardx`
|
|
287
|
+
- Ingest server: `@wardx/server`
|
|
288
|
+
|
|
289
|
+
The wire contract is protocol version 1. A runtime sends `POST /v1/sync` with JSON and gzip. The request header is `X-Wardx-Key`.
|
package/defaults.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"aggregateIntervalMs": 1000,
|
|
3
|
+
"syncIntervalMs": 15000,
|
|
4
|
+
"syncJitterMin": 0.85,
|
|
5
|
+
"syncJitterMax": 1.15,
|
|
6
|
+
"maxBufferedEvents": 5000,
|
|
7
|
+
"maxBufferedLogs": 2000,
|
|
8
|
+
"maxFrameBytes": 524288,
|
|
9
|
+
"maxSeriesPerMetric": 1000,
|
|
10
|
+
"maxDimensionKeys": 8,
|
|
11
|
+
"maxDimensionValueLength": 64,
|
|
12
|
+
"httpTimeoutMs": 10000,
|
|
13
|
+
"histogramBuckets": [10, 25, 50, 100, 250, 500, 1000]
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wardx/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime-agnostic Wardx engine for metrics, events, logs, Remote Config, and experiments.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"defaults.json"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/WardxCore.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { MetricsRegistry } from './metrics/MetricsRegistry.js';
|
|
2
|
+
import { EventBuffer } from './buffers/EventBuffer.js';
|
|
3
|
+
import { LogBuffer } from './buffers/LogBuffer.js';
|
|
4
|
+
import { ConfigStore } from './config/ConfigStore.js';
|
|
5
|
+
import { ExperimentResolver } from './config/ExperimentResolver.js';
|
|
6
|
+
import { FrameBuilder } from './frame/FrameBuilder.js';
|
|
7
|
+
import { InternalMetrics } from './internal/InternalMetrics.js';
|
|
8
|
+
import { NOOP_COUNTER } from './metrics/Counter.js';
|
|
9
|
+
import { NOOP_GAUGE } from './metrics/Gauge.js';
|
|
10
|
+
import { NOOP_HISTOGRAM } from './metrics/Histogram.js';
|
|
11
|
+
import { startTimer } from './metrics/Timer.js';
|
|
12
|
+
import { emit } from './trace/emit.js';
|
|
13
|
+
import { wrapCounter, wrapGauge, wrapHistogram } from './trace/wrap.js';
|
|
14
|
+
|
|
15
|
+
export class WardxCore {
|
|
16
|
+
constructor(settings) {
|
|
17
|
+
this.settings = settings;
|
|
18
|
+
this.stopped = false;
|
|
19
|
+
this._tracer = settings.tracer ?? null;
|
|
20
|
+
this._wrappers = this._tracer ? new WeakMap() : null;
|
|
21
|
+
this.internal = new InternalMetrics();
|
|
22
|
+
this.metrics = new MetricsRegistry({
|
|
23
|
+
maxSeriesPerMetric: settings.maxSeriesPerMetric,
|
|
24
|
+
maxDimensionKeys: settings.maxDimensionKeys,
|
|
25
|
+
maxDimensionValueLength: settings.maxDimensionValueLength,
|
|
26
|
+
defaultHistogramBuckets: settings.histogramBuckets,
|
|
27
|
+
onCardinalityDropped: () => {
|
|
28
|
+
this.internal.cardinalityDropped += 1;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
this.events = new EventBuffer(settings.maxBufferedEvents);
|
|
32
|
+
this.logs = new LogBuffer(settings.maxBufferedLogs);
|
|
33
|
+
this.configStore = new ConfigStore();
|
|
34
|
+
this.experiments = new ExperimentResolver({
|
|
35
|
+
privacySalt: settings.privacySalt,
|
|
36
|
+
onExposure: (payload) => {
|
|
37
|
+
this.event('experiment.exposure', payload);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
this.seq = 0;
|
|
41
|
+
this.pendingFrames = [];
|
|
42
|
+
this.windowStart = Date.now();
|
|
43
|
+
this.log = {
|
|
44
|
+
debug: (message, attrs) => this._log('debug', message, attrs),
|
|
45
|
+
info: (message, attrs) => this._log('info', message, attrs),
|
|
46
|
+
warn: (message, attrs) => this._log('warn', message, attrs),
|
|
47
|
+
error: (message, attrs) => this._log('error', message, attrs)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
counter(name, dims) {
|
|
52
|
+
return this._wrap(this.metrics.counter(name, dims), NOOP_COUNTER, (series, noop) =>
|
|
53
|
+
wrapCounter(this._tracer, series, noop, name, dims)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
gauge(name, dims) {
|
|
58
|
+
return this._wrap(this.metrics.gauge(name, dims), NOOP_GAUGE, (series, noop) =>
|
|
59
|
+
wrapGauge(this._tracer, series, noop, name, dims)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
histogram(name, a, b) {
|
|
64
|
+
return this._wrap(this.metrics.histogram(name, a, b), NOOP_HISTOGRAM, (series, noop) =>
|
|
65
|
+
wrapHistogram(this._tracer, series, noop, name)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
timer(name, dims) {
|
|
70
|
+
if (this._tracer === null) return this.metrics.timer(name, dims);
|
|
71
|
+
return startTimer((duration, endDims) => {
|
|
72
|
+
const merged = endDims ? { ...(dims || {}), ...endDims } : dims;
|
|
73
|
+
this.histogram(name, merged).observe(duration);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
event(name, attrs) {
|
|
78
|
+
const dropped = !this.events.push(name, attrs);
|
|
79
|
+
if (dropped) this.internal.eventsDropped += 1;
|
|
80
|
+
emit(this._tracer, 'event', { name, attrs: attrs ?? null, dropped });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_log(level, message, attrs) {
|
|
84
|
+
const dropped = !this.logs.push(level, message, attrs);
|
|
85
|
+
if (dropped) this.internal.logsDropped += 1;
|
|
86
|
+
emit(this._tracer, 'log', { level, message, attrs: attrs ?? null, dropped });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_wrap(series, noopSentinel, factory) {
|
|
90
|
+
if (this._tracer === null) return series;
|
|
91
|
+
if (series === noopSentinel) return factory(series, true);
|
|
92
|
+
let wrapped = this._wrappers.get(series);
|
|
93
|
+
if (wrapped) return wrapped;
|
|
94
|
+
wrapped = factory(series, false);
|
|
95
|
+
this._wrappers.set(series, wrapped);
|
|
96
|
+
return wrapped;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
configGet(key, fallback, context) {
|
|
100
|
+
if (!this.configStore.has(key)) return fallback;
|
|
101
|
+
const remote = this.configStore.getRaw(key);
|
|
102
|
+
if (!context || context.subjectId === undefined || context.subjectId === null) {
|
|
103
|
+
return remote;
|
|
104
|
+
}
|
|
105
|
+
return this.experiments.resolve(
|
|
106
|
+
key,
|
|
107
|
+
remote,
|
|
108
|
+
context.subjectId,
|
|
109
|
+
this.configStore.experimentsByKey
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
experimentGoal(name, context) {
|
|
114
|
+
if (!context || context.subjectId === undefined || context.subjectId === null) {
|
|
115
|
+
throw new Error('experiment.goal requires subjectId');
|
|
116
|
+
}
|
|
117
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
118
|
+
throw new Error('experiment.goal requires a metric name');
|
|
119
|
+
}
|
|
120
|
+
const subject = this.experiments.hashSubject(context.subjectId);
|
|
121
|
+
const experiments = this.experiments.relevantExperiments(
|
|
122
|
+
context.subjectId,
|
|
123
|
+
this.configStore.experiments
|
|
124
|
+
);
|
|
125
|
+
const payload = {
|
|
126
|
+
metric: name,
|
|
127
|
+
subject,
|
|
128
|
+
experiments
|
|
129
|
+
};
|
|
130
|
+
if (context.value !== undefined) payload.value = context.value;
|
|
131
|
+
this.event('experiment.goal', payload);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
applyConfig(version, config) {
|
|
135
|
+
this.configStore.applySnapshot({
|
|
136
|
+
version,
|
|
137
|
+
values: config.values,
|
|
138
|
+
experiments: config.experiments
|
|
139
|
+
});
|
|
140
|
+
this.internal.configVersion = version;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
snapshotIfDirty() {
|
|
144
|
+
if (
|
|
145
|
+
!this.metrics.isDirty() &&
|
|
146
|
+
this.events.length === 0 &&
|
|
147
|
+
this.logs.length === 0 &&
|
|
148
|
+
!this.internal.hasCounterActivity()
|
|
149
|
+
) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return this.snapshotFrame();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
snapshotFrame() {
|
|
156
|
+
const to = Date.now();
|
|
157
|
+
const from = this.windowStart;
|
|
158
|
+
this.windowStart = to;
|
|
159
|
+
this.internal.eventsBuffered = this.events.length;
|
|
160
|
+
this.internal.logsBuffered = this.logs.length;
|
|
161
|
+
const metrics = this.metrics.snapshotAndReset();
|
|
162
|
+
const events = this.events.swap();
|
|
163
|
+
const logs = this.logs.swap();
|
|
164
|
+
const internal = this.internal.snapshotAndReset();
|
|
165
|
+
const frame = FrameBuilder.build({
|
|
166
|
+
seq: ++this.seq,
|
|
167
|
+
from,
|
|
168
|
+
to,
|
|
169
|
+
metrics,
|
|
170
|
+
events,
|
|
171
|
+
logs,
|
|
172
|
+
internal
|
|
173
|
+
});
|
|
174
|
+
const fitted = FrameBuilder.fitToMaxBytes(frame, this.settings.maxFrameBytes);
|
|
175
|
+
this.internal.logsDropped += fitted.droppedLogs;
|
|
176
|
+
this.internal.eventsDropped += fitted.droppedEvents;
|
|
177
|
+
this.pendingFrames.push(fitted.frame);
|
|
178
|
+
emit(this._tracer, 'frame', {
|
|
179
|
+
seq: fitted.frame.seq,
|
|
180
|
+
from: fitted.frame.from,
|
|
181
|
+
to: fitted.frame.to,
|
|
182
|
+
counters: fitted.frame.metrics.counters.length,
|
|
183
|
+
gauges: fitted.frame.metrics.gauges.length,
|
|
184
|
+
histograms: fitted.frame.metrics.histograms.length,
|
|
185
|
+
events: fitted.frame.events.length,
|
|
186
|
+
logs: fitted.frame.logs.length,
|
|
187
|
+
droppedLogs: fitted.droppedLogs,
|
|
188
|
+
droppedEvents: fitted.droppedEvents
|
|
189
|
+
});
|
|
190
|
+
return fitted;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
takePendingFrames() {
|
|
194
|
+
const frames = this.pendingFrames;
|
|
195
|
+
this.pendingFrames = [];
|
|
196
|
+
return frames;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class EventBuffer {
|
|
2
|
+
constructor(maxBufferedEvents) {
|
|
3
|
+
if (!Number.isFinite(maxBufferedEvents) || maxBufferedEvents < 1) {
|
|
4
|
+
throw new Error('maxBufferedEvents must be >= 1');
|
|
5
|
+
}
|
|
6
|
+
this.max = maxBufferedEvents;
|
|
7
|
+
this.buf = [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get length() {
|
|
11
|
+
return this.buf.length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
push(name, attrs) {
|
|
15
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
16
|
+
throw new Error('event name must be a non-empty string');
|
|
17
|
+
}
|
|
18
|
+
if (this.buf.length >= this.max) return false;
|
|
19
|
+
this.buf.push([Date.now(), name, attrs ?? null]);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
swap() {
|
|
24
|
+
const sealed = this.buf;
|
|
25
|
+
this.buf = [];
|
|
26
|
+
return sealed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { LOG_RANK } from '../protocol.js';
|
|
2
|
+
|
|
3
|
+
export class LogBuffer {
|
|
4
|
+
constructor(maxBufferedLogs) {
|
|
5
|
+
if (!Number.isFinite(maxBufferedLogs) || maxBufferedLogs < 1) {
|
|
6
|
+
throw new Error('maxBufferedLogs must be >= 1');
|
|
7
|
+
}
|
|
8
|
+
this.max = maxBufferedLogs;
|
|
9
|
+
this.buf = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get length() {
|
|
13
|
+
return this.buf.length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
push(level, message, attrs) {
|
|
17
|
+
if (!Object.prototype.hasOwnProperty.call(LOG_RANK, level)) {
|
|
18
|
+
throw new Error(`invalid log level: ${level}`);
|
|
19
|
+
}
|
|
20
|
+
if (typeof message !== 'string' || message.length === 0) {
|
|
21
|
+
throw new Error('log message must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
const entry = [Date.now(), level, message, attrs ?? null];
|
|
24
|
+
if (this.buf.length < this.max) {
|
|
25
|
+
this.buf.push(entry);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const incomingRank = LOG_RANK[level];
|
|
29
|
+
let victim = -1;
|
|
30
|
+
let victimRank = incomingRank;
|
|
31
|
+
for (let i = 0; i < this.buf.length; i++) {
|
|
32
|
+
const rank = LOG_RANK[this.buf[i][1]];
|
|
33
|
+
if (rank < victimRank) {
|
|
34
|
+
victimRank = rank;
|
|
35
|
+
victim = i;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (victim === -1) return false;
|
|
39
|
+
this.buf[victim] = entry;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
swap() {
|
|
44
|
+
const sealed = this.buf;
|
|
45
|
+
this.buf = [];
|
|
46
|
+
return sealed;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { indexExperimentsByKey } from './ExperimentResolver.js';
|
|
2
|
+
|
|
3
|
+
export class ConfigStore {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.version = 0;
|
|
6
|
+
this.values = Object.create(null);
|
|
7
|
+
this.experiments = [];
|
|
8
|
+
this.experimentsByKey = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
applySnapshot(snapshot) {
|
|
12
|
+
if (!snapshot || typeof snapshot !== 'object') {
|
|
13
|
+
throw new Error('config snapshot must be an object');
|
|
14
|
+
}
|
|
15
|
+
if (typeof snapshot.version !== 'number' || !Number.isFinite(snapshot.version)) {
|
|
16
|
+
throw new Error('config snapshot version must be a finite number');
|
|
17
|
+
}
|
|
18
|
+
this.version = snapshot.version;
|
|
19
|
+
this.values = snapshot.values && typeof snapshot.values === 'object' ? snapshot.values : Object.create(null);
|
|
20
|
+
this.experiments = Array.isArray(snapshot.experiments) ? snapshot.experiments : [];
|
|
21
|
+
this.experimentsByKey = indexExperimentsByKey(this.experiments);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
has(key) {
|
|
25
|
+
return Object.prototype.hasOwnProperty.call(this.values, key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getRaw(key) {
|
|
29
|
+
return this.values[key];
|
|
30
|
+
}
|
|
31
|
+
}
|