Skip to content

Commit 9625a8f

Browse files
authored
✨[RUM-10962][Remote config] support dynamic options (#3743)
* bootstrap types final types not yet available on the backend side * ♻️ rename scenario * 👷avoid issue when testing the npm setup locally * ✨ add cookie strategy * ✨ add supported contexts * ✨add dom strategy * Trigger ci * disallow to retrieve password input value attribute * 👌remove redundant check and properties * 👌simplify some tests + use appendElement * ✅e2e: ensure configuration is applied before running assertions particularly flaky on firefox, locally observed between 200ms and 600ms before application * 👌simplify type * 👌warn when no setup available * 👌use alternative schema for context property
1 parent 8543cc4 commit 9625a8f

File tree

8 files changed

+740
-106
lines changed

8 files changed

+740
-106
lines changed

packages/rum-core/src/boot/preStartRum.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export function createPreStartStrategy(
171171
callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi })
172172

173173
if (initConfiguration.remoteConfigurationId) {
174-
fetchAndApplyRemoteConfiguration(initConfiguration)
174+
fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext })
175175
.then((initConfiguration) => {
176176
if (initConfiguration) {
177177
doInit(initConfiguration)

packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts

Lines changed: 287 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { DefaultPrivacyLevel, INTAKE_SITE_US1, display } from '@datadog/browser-core'
2-
import { interceptRequests } from '@datadog/browser-core/test'
1+
import {
2+
DefaultPrivacyLevel,
3+
INTAKE_SITE_US1,
4+
display,
5+
setCookie,
6+
deleteCookie,
7+
ONE_MINUTE,
8+
createContextManager,
9+
} from '@datadog/browser-core'
10+
import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test'
11+
import { appendElement } from '../../../test'
312
import type { RumInitConfiguration } from './configuration'
413
import type { RumRemoteConfiguration } from './remoteConfiguration'
514
import { applyRemoteConfiguration, buildEndpoint, fetchRemoteConfiguration } from './remoteConfiguration'
@@ -13,14 +22,13 @@ const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = {
1322
}
1423

1524
describe('remoteConfiguration', () => {
16-
let interceptor: ReturnType<typeof interceptRequests>
17-
18-
beforeEach(() => {
19-
interceptor = interceptRequests()
20-
})
21-
2225
describe('fetchRemoteConfiguration', () => {
2326
const configuration = { remoteConfigurationId: 'xxx' } as RumInitConfiguration
27+
let interceptor: ReturnType<typeof interceptRequests>
28+
29+
beforeEach(() => {
30+
interceptor = interceptRequests()
31+
})
2432

2533
it('should fetch the remote configuration', async () => {
2634
interceptor.withFetch(() =>
@@ -92,9 +100,31 @@ describe('remoteConfiguration', () => {
92100

93101
describe('applyRemoteConfiguration', () => {
94102
let displaySpy: jasmine.Spy
103+
let supportedContextManagers: {
104+
user: ReturnType<typeof createContextManager>
105+
context: ReturnType<typeof createContextManager>
106+
}
107+
108+
function expectAppliedRemoteConfigurationToBe(
109+
actual: Partial<RumRemoteConfiguration>,
110+
expected: Partial<RumInitConfiguration>
111+
) {
112+
const rumRemoteConfiguration: RumRemoteConfiguration = {
113+
applicationId: 'yyy',
114+
...actual,
115+
}
116+
expect(
117+
applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration, supportedContextManagers)
118+
).toEqual({
119+
...DEFAULT_INIT_CONFIGURATION,
120+
applicationId: 'yyy',
121+
...expected,
122+
})
123+
}
95124

96125
beforeEach(() => {
97126
displaySpy = spyOn(display, 'error')
127+
supportedContextManagers = { user: createContextManager(), context: createContextManager() }
98128
})
99129

100130
it('should override the initConfiguration options with the ones from the remote configuration', () => {
@@ -120,7 +150,9 @@ describe('remoteConfiguration', () => {
120150
],
121151
defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW,
122152
}
123-
expect(applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration)).toEqual({
153+
expect(
154+
applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration, supportedContextManagers)
155+
).toEqual({
124156
applicationId: 'yyy',
125157
clientToken: 'xxx',
126158
service: 'xxx',
@@ -138,30 +170,258 @@ describe('remoteConfiguration', () => {
138170
})
139171

140172
it('should display an error if the remote config contains invalid regex', () => {
141-
const rumRemoteConfiguration: RumRemoteConfiguration = {
142-
applicationId: 'yyy',
143-
allowedTrackingOrigins: [{ rcSerializedType: 'regex', value: 'Hello(?|!)' }],
144-
}
145-
expect(applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration)).toEqual({
146-
...DEFAULT_INIT_CONFIGURATION,
147-
applicationId: 'yyy',
148-
allowedTrackingOrigins: [undefined as any],
149-
})
173+
expectAppliedRemoteConfigurationToBe(
174+
{ allowedTrackingOrigins: [{ rcSerializedType: 'regex', value: 'Hello(?|!)' }] },
175+
{ allowedTrackingOrigins: [undefined as any] }
176+
)
150177
expect(displaySpy).toHaveBeenCalledWith("Invalid regex in the remote configuration: 'Hello(?|!)'")
151178
})
152179

153180
it('should display an error if an unsupported `rcSerializedType` is provided', () => {
154-
const rumRemoteConfiguration: RumRemoteConfiguration = {
155-
applicationId: 'yyy',
156-
allowedTrackingOrigins: [{ rcSerializedType: 'foo' as any, value: 'bar' }],
157-
}
158-
expect(applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration)).toEqual({
159-
...DEFAULT_INIT_CONFIGURATION,
160-
applicationId: 'yyy',
161-
allowedTrackingOrigins: [undefined as any],
162-
})
181+
expectAppliedRemoteConfigurationToBe(
182+
{ allowedTrackingOrigins: [{ rcSerializedType: 'foo' as any, value: 'bar' }] },
183+
{ allowedTrackingOrigins: [undefined as any] }
184+
)
163185
expect(displaySpy).toHaveBeenCalledWith('Unsupported remote configuration: "rcSerializedType": "foo"')
164186
})
187+
188+
it('should display an error if an unsupported `strategy` is provided', () => {
189+
expectAppliedRemoteConfigurationToBe(
190+
{ version: { rcSerializedType: 'dynamic', strategy: 'foo' as any } as any },
191+
{ version: undefined }
192+
)
193+
expect(displaySpy).toHaveBeenCalledWith('Unsupported remote configuration: "strategy": "foo"')
194+
})
195+
196+
describe('cookie strategy', () => {
197+
const COOKIE_NAME = 'unit_rc'
198+
199+
it('should resolve a configuration value from a cookie', () => {
200+
setCookie(COOKIE_NAME, 'my-version', ONE_MINUTE)
201+
registerCleanupTask(() => deleteCookie(COOKIE_NAME))
202+
expectAppliedRemoteConfigurationToBe(
203+
{ version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: COOKIE_NAME } },
204+
{ version: 'my-version' }
205+
)
206+
})
207+
208+
it('should resolve to undefined if the cookie is missing', () => {
209+
expectAppliedRemoteConfigurationToBe(
210+
{ version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: COOKIE_NAME } },
211+
{ version: undefined }
212+
)
213+
})
214+
})
215+
216+
describe('dom strategy', () => {
217+
beforeEach(() => {
218+
appendElement(`<div>
219+
<span id="version1" class="version">version-123</span>
220+
<span id="version2" class="version" data-version="version-456"></span>
221+
</div>`)
222+
})
223+
224+
it('should resolve a configuration value from an element text content', () => {
225+
expectAppliedRemoteConfigurationToBe(
226+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version1' } },
227+
{ version: 'version-123' }
228+
)
229+
})
230+
231+
it('should resolve a configuration value from an element text content and an extractor', () => {
232+
expectAppliedRemoteConfigurationToBe(
233+
{
234+
version: {
235+
rcSerializedType: 'dynamic',
236+
strategy: 'dom',
237+
selector: '#version1',
238+
extractor: { rcSerializedType: 'regex', value: '\\d+' },
239+
},
240+
},
241+
{ version: '123' }
242+
)
243+
})
244+
245+
it('should resolve a configuration value from the first element matching the selector', () => {
246+
expectAppliedRemoteConfigurationToBe(
247+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '.version' } },
248+
{ version: 'version-123' }
249+
)
250+
})
251+
252+
it('should resolve to undefined and display an error if the selector is invalid', () => {
253+
expectAppliedRemoteConfigurationToBe(
254+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '' } },
255+
{ version: undefined }
256+
)
257+
expect(displaySpy).toHaveBeenCalledWith("Invalid selector in the remote configuration: ''")
258+
})
259+
260+
it('should resolve to undefined if the element is missing', () => {
261+
expectAppliedRemoteConfigurationToBe(
262+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#missing' } },
263+
{ version: undefined }
264+
)
265+
})
266+
267+
it('should resolve a configuration value from an element attribute', () => {
268+
expectAppliedRemoteConfigurationToBe(
269+
{
270+
version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version2', attribute: 'data-version' },
271+
},
272+
{ version: 'version-456' }
273+
)
274+
})
275+
276+
it('should resolve to undefined if the element attribute is missing', () => {
277+
expectAppliedRemoteConfigurationToBe(
278+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version2', attribute: 'missing' } },
279+
{ version: undefined }
280+
)
281+
})
282+
283+
it('should resolve to undefined if trying to access a password input value attribute', () => {
284+
appendElement('<input id="pwd" type="password" value="foo" />')
285+
expectAppliedRemoteConfigurationToBe(
286+
{ version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#pwd', attribute: 'value' } },
287+
{ version: undefined }
288+
)
289+
})
290+
})
291+
292+
describe('with extractor', () => {
293+
const COOKIE_NAME = 'unit_rc'
294+
295+
beforeEach(() => {
296+
setCookie(COOKIE_NAME, 'my-version-123', ONE_MINUTE)
297+
})
298+
299+
afterEach(() => {
300+
deleteCookie(COOKIE_NAME)
301+
})
302+
303+
it('should resolve to the match on the value', () => {
304+
expectAppliedRemoteConfigurationToBe(
305+
{
306+
version: {
307+
rcSerializedType: 'dynamic',
308+
strategy: 'cookie',
309+
name: COOKIE_NAME,
310+
extractor: { rcSerializedType: 'regex', value: '\\d+' },
311+
},
312+
},
313+
{ version: '123' }
314+
)
315+
})
316+
317+
it('should resolve to the capture group on the value', () => {
318+
expectAppliedRemoteConfigurationToBe(
319+
{
320+
version: {
321+
rcSerializedType: 'dynamic',
322+
strategy: 'cookie',
323+
name: COOKIE_NAME,
324+
extractor: { rcSerializedType: 'regex', value: 'my-version-(\\d+)' },
325+
},
326+
},
327+
{ version: '123' }
328+
)
329+
})
330+
331+
it("should resolve to undefined if the value don't match", () => {
332+
expectAppliedRemoteConfigurationToBe(
333+
{
334+
version: {
335+
rcSerializedType: 'dynamic',
336+
strategy: 'cookie',
337+
name: COOKIE_NAME,
338+
extractor: { rcSerializedType: 'regex', value: 'foo' },
339+
},
340+
},
341+
{ version: undefined }
342+
)
343+
})
344+
345+
it('should display an error if the extractor is not a valid regex', () => {
346+
expectAppliedRemoteConfigurationToBe(
347+
{
348+
version: {
349+
rcSerializedType: 'dynamic',
350+
strategy: 'cookie',
351+
name: COOKIE_NAME,
352+
extractor: { rcSerializedType: 'regex', value: 'Hello(?|!)' },
353+
},
354+
},
355+
{ version: undefined }
356+
)
357+
expect(displaySpy).toHaveBeenCalledWith("Invalid regex in the remote configuration: 'Hello(?|!)'")
358+
})
359+
})
360+
361+
describe('supported contexts', () => {
362+
const COOKIE_NAME = 'unit_rc'
363+
364+
beforeEach(() => {
365+
setCookie(COOKIE_NAME, 'first.second', ONE_MINUTE)
366+
})
367+
368+
afterEach(() => {
369+
deleteCookie(COOKIE_NAME)
370+
})
371+
372+
it('should be resolved from the provided configuration', () => {
373+
expectAppliedRemoteConfigurationToBe(
374+
{
375+
user: [
376+
{
377+
key: 'id',
378+
value: {
379+
rcSerializedType: 'dynamic',
380+
strategy: 'cookie',
381+
name: COOKIE_NAME,
382+
extractor: { rcSerializedType: 'regex', value: '(\\w+)\\.\\w+' },
383+
},
384+
},
385+
{
386+
key: 'bar',
387+
value: {
388+
rcSerializedType: 'dynamic',
389+
strategy: 'cookie',
390+
name: COOKIE_NAME,
391+
extractor: { rcSerializedType: 'regex', value: '\\w+\\.(\\w+)' },
392+
},
393+
},
394+
],
395+
},
396+
{}
397+
)
398+
expect(supportedContextManagers.user.getContext()).toEqual({
399+
id: 'first',
400+
bar: 'second',
401+
})
402+
})
403+
404+
it('unresolved property should be set to undefined', () => {
405+
expectAppliedRemoteConfigurationToBe(
406+
{
407+
context: [
408+
{
409+
key: 'foo',
410+
value: {
411+
rcSerializedType: 'dynamic',
412+
strategy: 'cookie',
413+
name: 'missing-cookie',
414+
},
415+
},
416+
],
417+
},
418+
{}
419+
)
420+
expect(supportedContextManagers.context.getContext()).toEqual({
421+
foo: undefined,
422+
})
423+
})
424+
})
165425
})
166426

167427
describe('buildEndpoint', () => {

0 commit comments

Comments
 (0)