@contrast/assess 1.39.0 → 1.41.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/lib/dataflow/propagation/install/path/index.test.js +1 -1
- package/lib/dataflow/sinks/install/express/reflected-xss.js +1 -1
- package/lib/dataflow/sinks/install/express/unvalidated-redirect.js +1 -1
- package/lib/dataflow/sources/install/body-parser1.js +0 -1
- package/lib/dataflow/sources/install/body-parser1.test.js +4 -8
- package/lib/dataflow/sources/install/cookie-parser1.js +0 -1
- package/lib/dataflow/sources/install/cookie-parser1.test.js +2 -4
- package/lib/dataflow/sources/install/express/params.js +56 -37
- package/lib/dataflow/sources/install/express/params.test.js +80 -73
- package/lib/dataflow/sources/install/express/parsedUrl.js +45 -28
- package/lib/dataflow/sources/install/express/parsedUrl.test.js +70 -29
- package/lib/dataflow/sources/install/qs6.js +0 -1
- package/lib/dataflow/sources/install/restify/router.js +0 -1
- package/lib/dataflow/sources/install/restify/router.test.js +3 -5
- package/lib/get-source-context.js +33 -12
- package/lib/get-source-context.test.js +33 -5
- package/lib/make-source-context.js +6 -3
- package/lib/sampler/common.js +156 -0
- package/lib/sampler/common.test.js +101 -0
- package/lib/{sampler.js → sampler/index.js} +25 -59
- package/lib/{sampler.test.js → sampler/index.test.js} +37 -25
- package/package.json +10 -9
- package/lib/dataflow/sinks/install/fs-original.js +0 -170
|
@@ -32,23 +32,44 @@ module.exports = function(core) {
|
|
|
32
32
|
} = core;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
+
* getSourceContext must be used by all instrumentation to make sure that the
|
|
36
|
+
* - assess store is available
|
|
37
|
+
* - assess is enabled for this request
|
|
38
|
+
* - instrumentation is not locked
|
|
39
|
+
*
|
|
40
|
+
* Any code that does not use this function and directly accesses the assess
|
|
41
|
+
* source context will recurse until the stack is blown if the following
|
|
42
|
+
* checks are not correctly made.
|
|
43
|
+
*
|
|
35
44
|
* @param {import('./constants.js').InstrumentationType} type
|
|
36
45
|
* @returns {import('@contrast/assess').SourceContext|null} the assess store
|
|
37
46
|
*/
|
|
38
47
|
return core.assess.getSourceContext = function getSourceContext(type, ...rest) {
|
|
39
48
|
const ctx = sources.getStore()?.assess;
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
|
|
50
|
+
// the following logging used to be done by the caller, but has been moved
|
|
51
|
+
// here as opposed to overloading `ctx.policy` with a special value so the
|
|
52
|
+
// caller could determine whether no source context was available or the
|
|
53
|
+
// request is being intentionally excluded. A negative of this is that the
|
|
54
|
+
// function name is not available to be included in the log.
|
|
55
|
+
if (!ctx) {
|
|
56
|
+
if (type === InstrumentationType.SOURCE) {
|
|
57
|
+
// because this is a real error, and we don't have the function name
|
|
58
|
+
// that the caller previously logged, we generate a stack trace to
|
|
59
|
+
// capture that information.
|
|
60
|
+
const err = new Error('No source context found');
|
|
61
|
+
core.logger.warn({ err }, 'assess running outside of request scope');
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// there is a context, but if policy is null then assess is intentionally
|
|
66
|
+
// disabled (i.e., url exclusion or the request is not sampled).
|
|
67
|
+
if (!ctx.policy) {
|
|
68
|
+
core.logger.trace('Assess intentionally disabled for this request');
|
|
69
|
+
return null;
|
|
70
|
+
} else if (instrumentation.isLocked()) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
52
73
|
|
|
53
74
|
switch (type) {
|
|
54
75
|
case InstrumentationType.PROPAGATOR: {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { expect } = require('chai');
|
|
4
4
|
const { Event } = require('@contrast/common');
|
|
5
5
|
const { initAssessFixture } = require('@contrast/test/fixtures');
|
|
6
|
+
const sinon = require('sinon');
|
|
6
7
|
const {
|
|
7
8
|
InstrumentationType: { SOURCE, PROPAGATOR, RULE }
|
|
8
9
|
} = require('./constants');
|
|
@@ -36,8 +37,35 @@ describe('assess getSourceContext', function () {
|
|
|
36
37
|
expect(assessStore.policy.enabledRules).to.have.length.greaterThan(5);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
it('
|
|
40
|
+
it('return null when not in request scope', function() {
|
|
40
41
|
expect(core.assess.getSourceContext()).to.be.null;
|
|
42
|
+
expect(core.logger.warn.callCount).equal(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('return null and log when getSourceContext(SOURCE) not in request scope', function() {
|
|
46
|
+
expect(core.assess.getSourceContext(SOURCE)).to.be.null;
|
|
47
|
+
expect(core.logger.warn.callCount).equal(1);
|
|
48
|
+
expect(core.logger.warn.args[0][1]).to.include('outside of request scope');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('return null and log when intentionally disabled (policy === null)', function() {
|
|
52
|
+
simulateRequestScope(() => {
|
|
53
|
+
core.scopes.sources.getStore().assess.policy = null;
|
|
54
|
+
expect(core.assess.getSourceContext()).null;
|
|
55
|
+
// lots of code writes trace logs
|
|
56
|
+
const last = core.logger.trace.lastCall;
|
|
57
|
+
expect(last.args[0]).to.include('Assess intentionally disabled');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('return null and do not log when instrumentation is locked', function() {
|
|
62
|
+
simulateRequestScope(() => {
|
|
63
|
+
core.scopes.instrumentation.isLocked = sinon.stub().returns(true);
|
|
64
|
+
expect(core.assess.getSourceContext()).null;
|
|
65
|
+
if (core.logger.trace.called) {
|
|
66
|
+
expect(core.logger.trace.lastCall.args[0]).not.include('Assess intentionally disabled');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
41
69
|
});
|
|
42
70
|
|
|
43
71
|
describe('getSourceContext()', function() {
|
|
@@ -48,7 +76,7 @@ describe('assess getSourceContext', function () {
|
|
|
48
76
|
});
|
|
49
77
|
});
|
|
50
78
|
|
|
51
|
-
describe('.getSourceContext(SOURCE
|
|
79
|
+
describe('.getSourceContext(SOURCE)', function() {
|
|
52
80
|
it('returns assess store when max source event threshold is not met', function() {
|
|
53
81
|
simulateRequestScope(() => {
|
|
54
82
|
execStoreAssertions(core.assess.getSourceContext(SOURCE));
|
|
@@ -64,10 +92,10 @@ describe('assess getSourceContext', function () {
|
|
|
64
92
|
});
|
|
65
93
|
});
|
|
66
94
|
|
|
67
|
-
describe('.getSourceContext(PROPAGATOR
|
|
95
|
+
describe('.getSourceContext(PROPAGATOR)', function() {
|
|
68
96
|
it('returns assess store when max propagation event threshold is not met', function() {
|
|
69
97
|
simulateRequestScope(() => {
|
|
70
|
-
execStoreAssertions(core.assess.getSourceContext(
|
|
98
|
+
execStoreAssertions(core.assess.getSourceContext(PROPAGATOR));
|
|
71
99
|
});
|
|
72
100
|
});
|
|
73
101
|
|
|
@@ -80,7 +108,7 @@ describe('assess getSourceContext', function () {
|
|
|
80
108
|
});
|
|
81
109
|
});
|
|
82
110
|
|
|
83
|
-
describe('.getSourceContext(
|
|
111
|
+
describe('.getSourceContext(RULE, ruleId?)', function() {
|
|
84
112
|
it('returns assess store when ruleId is not passed', function() {
|
|
85
113
|
simulateRequestScope(() => {
|
|
86
114
|
execStoreAssertions(core.assess.getSourceContext(RULE));
|
|
@@ -50,11 +50,15 @@ module.exports = function(core) {
|
|
|
50
50
|
// default policy to `null` until it is set later below. this will cause
|
|
51
51
|
// all instrumentation to short-circuit, see `./get-source-context.js`.
|
|
52
52
|
policy: null,
|
|
53
|
-
reqData: {
|
|
53
|
+
reqData: {
|
|
54
|
+
method: req.method,
|
|
55
|
+
uriPath,
|
|
56
|
+
queries,
|
|
57
|
+
},
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
// check whether sampling allows processing
|
|
57
|
-
ctx.sampleInfo = assess.sampler?.getSampleInfo
|
|
61
|
+
ctx.sampleInfo = assess.sampler?.getSampleInfo(sourceData) ?? null;
|
|
58
62
|
if (ctx.sampleInfo?.canSample === false) return ctx;
|
|
59
63
|
|
|
60
64
|
// set policy - can be returned as `null` if request is url-excluded.
|
|
@@ -65,7 +69,6 @@ module.exports = function(core) {
|
|
|
65
69
|
ctx.reqData.headers = { ...req.headers }; // copy to avoid storing tracked values
|
|
66
70
|
ctx.reqData.ip = req.socket.remoteAddress;
|
|
67
71
|
ctx.reqData.httpVersion = req.httpVersion;
|
|
68
|
-
ctx.reqData.method = req.method;
|
|
69
72
|
if (ctx.reqData.headers['content-type'])
|
|
70
73
|
ctx.reqData.contentType = StringPrototypeToLowerCase.call(ctx.reqData.headers['content-type']);
|
|
71
74
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2024 Contrast Security, Inc
|
|
3
|
+
* Contact: support@contrastsecurity.com
|
|
4
|
+
* License: Commercial
|
|
5
|
+
|
|
6
|
+
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
+
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
+
* made available through public repositories, use of this Software is subject to
|
|
9
|
+
* the applicable End User Licensing Agreement found at
|
|
10
|
+
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
+
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
+
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
+
* way not consistent with the End User License Agreement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const SamplingStrategies = {
|
|
19
|
+
AssessTurnedOff: 1,
|
|
20
|
+
Probabilistic: 2,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class RouteAnalysisMonitor {
|
|
24
|
+
constructor(core) {
|
|
25
|
+
this._core = core;
|
|
26
|
+
// default behavior is to sample
|
|
27
|
+
this._defaultAnalysisInfo = { paused: false };
|
|
28
|
+
// cache keys come from discovered route `normalizedUrl`s, so size is controlled
|
|
29
|
+
this._normalCache = new Map();
|
|
30
|
+
this._ttl = core.config.assess.probabilistic_sampling.route_monitor.ttl_ms;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} reqData
|
|
35
|
+
* @param {string} reqData.uriPath
|
|
36
|
+
* @returns {AnalysisInfo}
|
|
37
|
+
*/
|
|
38
|
+
getAnalysisInfo({ method, uriPath }) {
|
|
39
|
+
const normalizedUrl = this._core.routeCoverage.uriPathToNormalizedUrl(uriPath);
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
|
|
42
|
+
if (normalizedUrl) {
|
|
43
|
+
const key = `${method}:${normalizedUrl}`;
|
|
44
|
+
let routeMeta = this._normalCache.get(key);
|
|
45
|
+
|
|
46
|
+
// not in cache, not paused
|
|
47
|
+
if (!routeMeta) {
|
|
48
|
+
routeMeta = {
|
|
49
|
+
pauseEnd: now + this._ttl,
|
|
50
|
+
normalizedUrl,
|
|
51
|
+
};
|
|
52
|
+
this._normalCache.set(key, routeMeta);
|
|
53
|
+
|
|
54
|
+
return { paused: false, ...routeMeta };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// unpause if pauseEnd expired
|
|
58
|
+
if (routeMeta.pauseEnd < now) {
|
|
59
|
+
// update so route lookup will be paused next time
|
|
60
|
+
routeMeta.pauseEnd = now + this._ttl;
|
|
61
|
+
|
|
62
|
+
return { paused: false, ...routeMeta };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// was in cache and still paused
|
|
66
|
+
return { paused: true, ...routeMeta };
|
|
67
|
+
} else {
|
|
68
|
+
// todo - handle "dynamic" routes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return this._defaultAnalysisInfo;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class BaseSampler {
|
|
76
|
+
constructor(strategy, opts) {
|
|
77
|
+
// save strategy and opts on instance so they can be checked before re-initializing
|
|
78
|
+
this.strategy = strategy;
|
|
79
|
+
this.opts = opts;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Allows Assess to turn off at runtime e.g. disabled via TeamServer DTM
|
|
84
|
+
class AssessTurnedOffSampler extends BaseSampler {
|
|
85
|
+
constructor() {
|
|
86
|
+
super(SamplingStrategies.AssessTurnedOff);
|
|
87
|
+
this._sampleInfo = Object.seal({ canSample: false });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getSampleInfo() {
|
|
91
|
+
return this._sampleInfo;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class ProbabilisticSampler extends BaseSampler {
|
|
96
|
+
constructor(opts) {
|
|
97
|
+
super(SamplingStrategies.Probabilistic, opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getSampleInfo(sourceInfo) {
|
|
101
|
+
const { base_probability } = this.opts;
|
|
102
|
+
const { reqData } = sourceInfo.store.assess;
|
|
103
|
+
|
|
104
|
+
// base caclulation
|
|
105
|
+
const rand = Math.random();
|
|
106
|
+
const canSample = rand < base_probability;
|
|
107
|
+
const sampleInfo = { canSample, base_probability, rand };
|
|
108
|
+
|
|
109
|
+
// check route monitoring before sampling
|
|
110
|
+
if (canSample) {
|
|
111
|
+
const routeInfo = this.routeMonitor?.getAnalysisInfo(reqData);
|
|
112
|
+
|
|
113
|
+
if (routeInfo) {
|
|
114
|
+
// don't sample if analysis is paused
|
|
115
|
+
routeInfo.paused && (sampleInfo.canSample = false);
|
|
116
|
+
|
|
117
|
+
// append any additional metadata to sample info
|
|
118
|
+
routeInfo.pauseEnd && (sampleInfo.pauseEnd = routeInfo.pauseEnd);
|
|
119
|
+
routeInfo.normalizedUrl && (sampleInfo.normalizedUrl = routeInfo.normalizedUrl);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return sampleInfo;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class SamplerBuilder {
|
|
128
|
+
constructor(core) {
|
|
129
|
+
this.builders = new Map([
|
|
130
|
+
//
|
|
131
|
+
[SamplingStrategies.AssessTurnedOff, () => new AssessTurnedOffSampler()],
|
|
132
|
+
//
|
|
133
|
+
[SamplingStrategies.Probabilistic, (opts) => {
|
|
134
|
+
const sampler = new ProbabilisticSampler(opts);
|
|
135
|
+
|
|
136
|
+
if (opts?.route_monitor?.enable)
|
|
137
|
+
sampler.routeMonitor = new RouteAnalysisMonitor(core);
|
|
138
|
+
|
|
139
|
+
return sampler;
|
|
140
|
+
}],
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
build(strategy, opts) {
|
|
145
|
+
return this.builders.get(strategy)(opts);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
RouteAnalysisMonitor,
|
|
151
|
+
BaseSampler,
|
|
152
|
+
AssessTurnedOffSampler,
|
|
153
|
+
ProbabilisticSampler,
|
|
154
|
+
SamplerBuilder,
|
|
155
|
+
SamplingStrategies,
|
|
156
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { devNull } = require('node:os');
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
const mocks = require('@contrast/test/mocks');
|
|
7
|
+
const { RouteAnalysisMonitor } = require('./common');
|
|
8
|
+
const frameworkData = require('@contrast/test/data/framework-routing-data')();
|
|
9
|
+
|
|
10
|
+
describe('assess sampler classes', function() {
|
|
11
|
+
describe('RouteAnalysisMonitor', function() {
|
|
12
|
+
it('getAnalysisInfo returns null when no discovery data was registered', function() {
|
|
13
|
+
const monitor = new RouteAnalysisMonitor(initMockCore());
|
|
14
|
+
[
|
|
15
|
+
['/user/1', '/user/2', '/user/3', '/user/4'],
|
|
16
|
+
['/user/1/cart', '/user/2/cart', '/user/3/cart', '/user/4/cart'],
|
|
17
|
+
['/products/all', '/products/all'],
|
|
18
|
+
['/products/1', '/products/2', '/products/3', '/products/4'],
|
|
19
|
+
].forEach((uriPaths) => {
|
|
20
|
+
for (const uriPath of uriPaths) {
|
|
21
|
+
expect(monitor.getAnalysisInfo({ uriPath })).to.deep.equal({ paused: false });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
for (const [framework, testData] of Object.entries(frameworkData)) {
|
|
27
|
+
describe(`${framework} framework route monitoring`, function() {
|
|
28
|
+
let core, monitor;
|
|
29
|
+
|
|
30
|
+
before(function() {
|
|
31
|
+
core = initMockCore();
|
|
32
|
+
core.config.setValue('assess.probabilistic_sampling.route_monitor.ttl_ms', 500, 'CONFIGURATION_FILE');
|
|
33
|
+
monitor = new RouteAnalysisMonitor(core);
|
|
34
|
+
testData.forEach((d) => {
|
|
35
|
+
core.routeCoverage._normalizedUrlMapper.handleDiscover(d.routeInfo);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
testData.forEach(({ paths, routeInfo, skip, hasMapping }) => {
|
|
40
|
+
it(`${skip ? 'does not monitor' : 'monitors'} for normalizedUrl '${routeInfo.normalizedUrl}'`, async function() {
|
|
41
|
+
const groups = { paused: [], notPaused: [] };
|
|
42
|
+
let calls = 0;
|
|
43
|
+
|
|
44
|
+
// iterate at least twice if paths array has 1 element
|
|
45
|
+
[...paths, ...paths].forEach((uriPath, i) => {
|
|
46
|
+
const result = monitor.getAnalysisInfo({ uriPath, method: routeInfo.method });
|
|
47
|
+
calls++;
|
|
48
|
+
// update bucket
|
|
49
|
+
groups[result.paused ? 'paused' : 'notPaused'].push({ index: i, uriPath, ...result });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (hasMapping !== false) {
|
|
53
|
+
// first call was unpaused
|
|
54
|
+
expect(groups.notPaused).to.have.lengthOf(1);
|
|
55
|
+
expect(groups.notPaused[0]).to.have.property('index', 0);
|
|
56
|
+
// all other calls were paused
|
|
57
|
+
expect(groups.paused).to.have.lengthOf(calls - 1);
|
|
58
|
+
for (const result of groups.paused) {
|
|
59
|
+
expect(result.index).to.be.greaterThan(0);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
expect(groups.paused).to.have.lengthOf(0);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function initMockCore() {
|
|
72
|
+
const { CONTRAST_CONFIG_PATH } = process.env;
|
|
73
|
+
process.env.CONTRAST_CONFIG_PATH = devNull;
|
|
74
|
+
|
|
75
|
+
let core;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
core = {
|
|
79
|
+
// sampler needs this namespace to exist
|
|
80
|
+
assess: {},
|
|
81
|
+
// mocks
|
|
82
|
+
messages: new EventEmitter(),
|
|
83
|
+
logger: mocks.logger(),
|
|
84
|
+
};
|
|
85
|
+
// use actual config so we can get dynamic effective values with TS message
|
|
86
|
+
// updates (mock doesn't). we can also test new effective config mappings.
|
|
87
|
+
require('@contrast/config')(core);
|
|
88
|
+
require('@contrast/route-coverage')(core);
|
|
89
|
+
core.config.setValue('assess.enable', true, 'CONTRAST_UI');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
process.env.CONTRAST_CONFIG_PATH = CONTRAST_CONFIG_PATH;
|
|
92
|
+
|
|
93
|
+
console.dir(err);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// reset to orig value
|
|
98
|
+
process.env.CONTRAST_CONFIG_PATH = CONTRAST_CONFIG_PATH;
|
|
99
|
+
|
|
100
|
+
return core;
|
|
101
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
18
|
const { Event } = require('@contrast/common');
|
|
19
|
+
const { ConfigSource } = require('@contrast/config');
|
|
20
|
+
const { SamplerBuilder, SamplingStrategies } = require('./common');
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* @param {{
|
|
@@ -34,29 +36,40 @@ module.exports = function assess(core) {
|
|
|
34
36
|
messages,
|
|
35
37
|
} = core;
|
|
36
38
|
|
|
39
|
+
const samplerBuilder = new SamplerBuilder(core);
|
|
40
|
+
|
|
37
41
|
/**
|
|
38
42
|
* Initializes the Assess sampler only if there's a need, otherwise sets it to null.
|
|
39
43
|
* Clients will call the instance methods optionally: assess.sampler?.getSampleInfo().
|
|
40
44
|
* Determines sampler strategy and options based on effective config values.
|
|
41
45
|
*/
|
|
42
46
|
function initSampler() {
|
|
47
|
+
const isProd = config.getEffectiveValue('server.environment') === 'PRODUCTION';
|
|
48
|
+
const enableAssess = config.getEffectiveValue('assess.enable');
|
|
43
49
|
const baseProbability = config.getEffectiveValue('assess.probabilistic_sampling.base_probability');
|
|
50
|
+
const {
|
|
51
|
+
value: enableSampling,
|
|
52
|
+
source: samplingConfigSource
|
|
53
|
+
} = config._effectiveMap.get('assess.probabilistic_sampling.enable');
|
|
44
54
|
|
|
45
55
|
let strategy;
|
|
46
56
|
let opts;
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
// determine strategy and options
|
|
59
|
+
if (!enableAssess || baseProbability === 0) {
|
|
60
|
+
// if Assess was disabled by TS turn assess off i.e. sample 0% of requests
|
|
61
|
+
strategy = SamplingStrategies.AssessTurnedOff;
|
|
51
62
|
} else if (baseProbability < 1) {
|
|
63
|
+
// in PRODUCTION environments turn on sampling unless explicitly disabled
|
|
52
64
|
if (
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
enableSampling ||
|
|
66
|
+
(isProd && samplingConfigSource === ConfigSource.DEFAULT_VALUE)
|
|
55
67
|
) {
|
|
56
68
|
// strategy and opts can be more dynamic in the future
|
|
57
|
-
strategy =
|
|
69
|
+
strategy = SamplingStrategies.Probabilistic;
|
|
58
70
|
opts = {
|
|
59
|
-
base_probability: baseProbability
|
|
71
|
+
base_probability: baseProbability,
|
|
72
|
+
route_monitor: config.assess.probabilistic_sampling.route_monitor,
|
|
60
73
|
};
|
|
61
74
|
}
|
|
62
75
|
}
|
|
@@ -68,7 +81,7 @@ module.exports = function assess(core) {
|
|
|
68
81
|
assess.sampler?.opts?.base_probability !== opts?.base_probability
|
|
69
82
|
) {
|
|
70
83
|
logger.info({ strategy, opts }, 'updating assess sampler');
|
|
71
|
-
assess.sampler =
|
|
84
|
+
assess.sampler = samplerBuilder.build(strategy, opts);
|
|
72
85
|
}
|
|
73
86
|
} else {
|
|
74
87
|
if (assess.sampler) logger.info('assess sampling disabled');
|
|
@@ -79,58 +92,11 @@ module.exports = function assess(core) {
|
|
|
79
92
|
|
|
80
93
|
// initialize a first time
|
|
81
94
|
initSampler();
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
95
|
+
|
|
96
|
+
// and re-init again upon settings updates. we don't use the settings
|
|
97
|
+
// message argument, since all effective sampling configs will have
|
|
98
|
+
// been updated by @contrast/config (it registers listeners first).
|
|
85
99
|
messages.on(Event.SERVER_SETTINGS_UPDATE, initSampler);
|
|
86
100
|
|
|
87
101
|
return core.assess.sampler;
|
|
88
102
|
};
|
|
89
|
-
|
|
90
|
-
class BaseSampler {
|
|
91
|
-
constructor(strategy, opts) {
|
|
92
|
-
// save strategy and opts on instance so they can be checked before re-initializing
|
|
93
|
-
this.strategy = strategy;
|
|
94
|
-
this.opts = opts;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
class DisabledSampler extends BaseSampler {
|
|
99
|
-
constructor() {
|
|
100
|
-
super('disabled');
|
|
101
|
-
this._sampleInfo = Object.seal({ canSample: false });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
getSampleInfo() {
|
|
105
|
-
return this._sampleInfo;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
class ProbabilisticSampler extends BaseSampler {
|
|
110
|
-
constructor(opts) {
|
|
111
|
-
super('probabilistic', opts);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
getSampleInfo() {
|
|
115
|
-
const { base_probability } = this.opts;
|
|
116
|
-
const rand = Math.random();
|
|
117
|
-
const canSample = rand < base_probability;
|
|
118
|
-
return { canSample, base_probability, rand };
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
class SamplerFactory {
|
|
123
|
-
/**
|
|
124
|
-
* Used when Assess is disabled by TS; always returns value instructing not to sample
|
|
125
|
-
*/
|
|
126
|
-
static disabled() {
|
|
127
|
-
return new DisabledSampler();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Each request has fixed chance e.g. 0.05.
|
|
132
|
-
*/
|
|
133
|
-
static probabilistic(opts = {}) {
|
|
134
|
-
return new ProbabilisticSampler(opts);
|
|
135
|
-
}
|
|
136
|
-
}
|