@wardx/core 0.4.0 → 0.7.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 +11 -2
- package/package.json +5 -2
- package/src/WardxCore.js +15 -0
- package/src/config/hash.js +4 -6
- package/src/index.d.ts +1 -0
- package/src/metrics/HyperLogLog.js +2 -6
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ core.histogram('coins.award_size').observe(80, { grantId: 'g-80' });
|
|
|
117
117
|
`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.
|
|
118
118
|
|
|
119
119
|
`distinct(name, dims).add(identifier)` updates a fixed HyperLogLog sketch. The
|
|
120
|
-
engine hashes the identifier as `
|
|
120
|
+
engine hashes the identifier as `XXHash64(privacySalt || 0x00 || identifier)` and
|
|
121
121
|
discards it immediately; the frame contains only 512 HLL registers (`p=9`,
|
|
122
122
|
about 4.6% standard error). Keep `privacySalt` stable across workers and time
|
|
123
123
|
windows so equal identifiers map to equal registers.
|
|
@@ -239,7 +239,7 @@ bucket = hash / 2^32
|
|
|
239
239
|
|
|
240
240
|
If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
241
241
|
|
|
242
|
-
`subjectHash` returns
|
|
242
|
+
`subjectHash` returns 16 lowercase hex digits of `XXHash64(UTF8(privacySalt) || 0x00 || UTF8(subjectId))`.
|
|
243
243
|
|
|
244
244
|
## Use case 5: Build a frame for a custom transport
|
|
245
245
|
|
|
@@ -302,3 +302,12 @@ Internal series use the prefix `wardx.internal.`.
|
|
|
302
302
|
- Ingest server: `@wardx/server`
|
|
303
303
|
|
|
304
304
|
The wire contract is protocol version 1. A runtime sends `POST /v1/sync` with JSON and gzip. The request header is `X-Wardx-Key`.
|
|
305
|
+
|
|
306
|
+
## Retention activity
|
|
307
|
+
|
|
308
|
+
`WardxCore.retentionActivity(userId)` requires an explicit nonblank user ID and
|
|
309
|
+
emits salted `retention.activity` evidence into the existing event buffer. The
|
|
310
|
+
server persists UTC cohorts and exact received-user D1/D7/D30 returns; the core
|
|
311
|
+
does not infer activity or keep a durable client identity/outbox. Use the same
|
|
312
|
+
user ID and privacy salt across sessions and clients. See the
|
|
313
|
+
[Node retention contract](../node/README.md#user-retention-d1--d7--d30).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wardx/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Runtime-agnostic Wardx engine for metrics, events, logs, Remote Config, and experiments.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wardx",
|
|
@@ -40,5 +40,8 @@
|
|
|
40
40
|
],
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"xxhash-wasm": "1.1.0"
|
|
43
46
|
}
|
|
44
|
-
}
|
|
47
|
+
}
|
package/src/WardxCore.js
CHANGED
|
@@ -3,6 +3,7 @@ import { EventBuffer } from './buffers/EventBuffer.js';
|
|
|
3
3
|
import { LogBuffer } from './buffers/LogBuffer.js';
|
|
4
4
|
import { ConfigStore } from './config/ConfigStore.js';
|
|
5
5
|
import { ExperimentResolver } from './config/ExperimentResolver.js';
|
|
6
|
+
import { subjectHash } from './config/hash.js';
|
|
6
7
|
import { FrameBuilder } from './frame/FrameBuilder.js';
|
|
7
8
|
import { InternalMetrics } from './internal/InternalMetrics.js';
|
|
8
9
|
import { NOOP_COUNTER } from './metrics/Counter.js';
|
|
@@ -106,6 +107,20 @@ export class WardxCore {
|
|
|
106
107
|
return wrapped;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
retentionActivity(userId) {
|
|
111
|
+
if (typeof userId !== 'string' || userId.trim().length === 0) {
|
|
112
|
+
throw new Error('retentionActivity requires a non-empty userId');
|
|
113
|
+
}
|
|
114
|
+
const salt = this.settings.privacySalt;
|
|
115
|
+
if (typeof salt !== 'string' || salt.trim().length === 0) {
|
|
116
|
+
throw new Error('retentionActivity requires a non-empty privacySalt');
|
|
117
|
+
}
|
|
118
|
+
this.event('retention.activity', {
|
|
119
|
+
subject: subjectHash(salt, userId),
|
|
120
|
+
salt: subjectHash(salt, 'wardx.retention.identity')
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
identify(subjectId) {
|
|
110
125
|
if (subjectId === undefined || subjectId === null) {
|
|
111
126
|
this._subjectId = null;
|
package/src/config/hash.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import xxhash from 'xxhash-wasm';
|
|
2
|
+
|
|
3
|
+
const { h64ToString } = await xxhash();
|
|
2
4
|
|
|
3
5
|
const encoder = new TextEncoder();
|
|
4
6
|
|
|
@@ -25,9 +27,5 @@ export function assignmentHash(experimentId, subjectId, salt) {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function subjectHash(projectSalt, subjectId) {
|
|
28
|
-
return
|
|
29
|
-
.update(projectSalt, 'utf8')
|
|
30
|
-
.update('\0', 'utf8')
|
|
31
|
-
.update(subjectId, 'utf8')
|
|
32
|
-
.digest('hex');
|
|
30
|
+
return h64ToString(`${projectSalt}\0${subjectId}`);
|
|
33
31
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -440,6 +440,7 @@ export class WardxCore {
|
|
|
440
440
|
distinct(name: string, dims?: Dimensions | null): DistinctHandle;
|
|
441
441
|
timer(name: string, dims?: Dimensions | null): StopTimer;
|
|
442
442
|
event(name: string, attrs?: Attrs | null): void;
|
|
443
|
+
retentionActivity(userId: string): void;
|
|
443
444
|
identify(subjectId: string | null | undefined): void;
|
|
444
445
|
configGet<T>(key: string, fallback: T, context?: SubjectContext): T;
|
|
445
446
|
experimentGoal(name: string, context?: ExperimentGoalContext): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { subjectHash } from '../config/hash.js';
|
|
2
2
|
|
|
3
3
|
export const HLL_PRECISION = 9;
|
|
4
4
|
export const HLL_REGISTER_COUNT = 1 << HLL_PRECISION;
|
|
@@ -82,11 +82,7 @@ export class HyperLogLog {
|
|
|
82
82
|
if (typeof identifier !== 'string' || identifier.length === 0) {
|
|
83
83
|
throw new Error('distinct.add requires a non-empty string');
|
|
84
84
|
}
|
|
85
|
-
const digest =
|
|
86
|
-
.update(this.privacySalt, 'utf8')
|
|
87
|
-
.update('\0', 'utf8')
|
|
88
|
-
.update(identifier, 'utf8')
|
|
89
|
-
.digest();
|
|
85
|
+
const digest = Buffer.from(subjectHash(this.privacySalt, identifier), 'hex');
|
|
90
86
|
const index = (digest[0] << 1) | (digest[1] >> 7);
|
|
91
87
|
const rank = rankAfterIndex(digest);
|
|
92
88
|
if (rank > this.registers[index]) this.registers[index] = rank;
|