Skip to content
21 changes: 21 additions & 0 deletions projects/ngx-ui-watchtower/DESIGN.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs changes (keepalive/sendBeacon, window.top to ancestor-walk change) seem to be unrelated to the PR's scope, they deserve their own PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done: #33

Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,27 @@ async load(): Promise<UwtRumBootstrap | null> {
}
```

Settings that need a `RegExp` or a function — `pagesToExclude` above, or
`urlsToExclude` and `ignore` inside `telemetries` — belong in code like this
(`addXRayTraceIdHeader` too, unless it is a plain boolean). JSON can't carry
them, and a string in their place
would make the SDK throw each time it applies the option — for the page and
URL filters, on every page view or request.

The sink checks them before loading the SDK and **fails closed**: if any holds
something else, RUM doesn't start for the page, as with a broker returning
`null`, and a console warning names each setting and the type it expects
(never its value) in every environment. Dropping just the bad value would fail
open instead: the filters and `ignore` decide what is _not_ recorded, so RUM
would then record pages and URLs the app meant to keep out — personal data in
a URL among them.

Any optional setting that is `undefined` or `null` — a broker's JSON often
spells an unset field as `null` — counts as unset: it isn't checked, and the
default applies. For `allowCookies`, `enableXRay`, `endpoint` and `telemetries`
that is this library's own default; every other unset field is left out of the
SDK config, so the SDK's default applies.

### Per-logger levels

`minLevel` is the floor for everything; `levels` overrides it by name, either
Expand Down
6 changes: 6 additions & 0 deletions projects/ngx-ui-watchtower/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ export class MyRumCredentials implements UwtRumCredentialsProvider {
}
```

Settings that need a `RegExp` or a function — `pagesToExclude`, or
`urlsToExclude` and `ignore` inside `telemetries` — can't come from JSON. If
one arrives as a string, the sink doesn't start RUM for the page and warns in
the console, rather than let the SDK throw or record what you meant to
exclude. Set those in code.

### Log backend

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ export interface UwtRumAppMonitorConfig {
* and same-origin HTTP. The SDK's own default also includes `'replay'`
* (session replay via `rrweb`); this library does not enable it by
* default.
*
* Options the SDK matches with `RegExp.test` or calls as a function —
* `urlsToInclude`, `urlsToExclude`, `addXRayTraceIdHeader` (also a
* boolean), `ignore`, `interactionId` — must hold exactly that. JSON can't
* carry them, and a string there would make the SDK throw each time it
* applies the option, so RUM doesn't start for the page instead (with a
* console warning naming the option). Set them in code, not in a broker's
* answer.
*/
telemetries?: (string | (string | object)[])[];

Expand Down Expand Up @@ -214,10 +222,19 @@ export interface UwtRumAppMonitorConfig {
/** How the page id is derived from the URL. Default `'PATH'`. */
pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH';

/** Pages recorded, matched against the page id. Default matches every page. */
/**
* Pages recorded, matched against the page id. Default matches every page.
* `undefined` or `null` means unset; anything else that isn't an array of
* `RegExp`s (a string list from JSON) keeps RUM from starting for the page.
*/
pagesToInclude?: RegExp[];

/** Pages excluded from recording, matched against the page id. Default `[]`. */
/**
* Pages excluded from recording, matched against the page id. Default `[]`.
* `undefined` or `null` means unset; anything else that isn't an array of
* `RegExp`s (a string list from JSON) keeps RUM from starting for the page,
* rather than record what it should exclude.
*/
pagesToExclude?: RegExp[];

/** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,132 @@ describe('UwtRumTelemetrySink', () => {
expect(sameOrigin.test(`${origin}.attacker.example/api`)).toBe(false);
});

it('should not start RUM when a telemetry option needs a RegExp or a function but holds a string', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
configure({
loadImpl: async () =>
bootstrap({
telemetries: [
'errors',
[
'http',
{
urlsToExclude: ['/health'],
addXRayTraceIdHeader: ['/api'],
recordAllRequests: true
}
],
['performance', { ignore: 'resource', eventLimit: 5 }],
[
'interaction',
{ interactionId: 'id', events: [{ event: 'click' }] }
]
]
})
});
await sink.init();
sink.record('scenario', {});

expect(AwsRumCtor).not.toHaveBeenCalled();
expect(awsRumInstance.recordEvent).not.toHaveBeenCalled();
// Names and expected types only, never the values.
expect(warn).toHaveBeenCalledWith(
'[ngx-ui-watchtower] RUM not started, settings of the wrong type: ' +
'http.urlsToExclude (expected an array of RegExp), ' +
'http.addXRayTraceIdHeader (expected a boolean or an array of RegExp), ' +
'performance.ignore (expected a function), ' +
'interaction.interactionId (expected a function)'
);
});

it('should keep telemetry options that are RegExps, functions or booleans', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const ignore = () => false;
const telemetries = [
['http', { urlsToExclude: [/\/health/], addXRayTraceIdHeader: true }],
['errors', { ignore }]
];
configure({ loadImpl: async () => bootstrap({ telemetries }) });
await sink.init();

const config = AwsRumCtor.mock.calls[0][3] as { telemetries: unknown[] };
expect(config.telemetries).toEqual(telemetries);
expect(warn).not.toHaveBeenCalled();
});

it('should not start RUM when a page filter is not a RegExp list', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
configure({
loadImpl: async () =>
bootstrap({
pagesToInclude: [/^\/app/],
pagesToExclude: ['/admin'] as unknown as RegExp[]
})
});
await sink.init();

expect(AwsRumCtor).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'[ngx-ui-watchtower] RUM not started, settings of the wrong type: ' +
'pagesToExclude (expected an array of RegExp)'
);
});

it.each([
['a string', 'errors'],
['an object', { http: true }]
])(
'should not start RUM when telemetries is %s, not an array',
async (_label, telemetries) => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
configure({
loadImpl: async () =>
bootstrap({ telemetries: telemetries as unknown as string[] })
});
await sink.init();

expect(AwsRumCtor).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'[ngx-ui-watchtower] RUM not started, settings of the wrong type: ' +
'telemetries (expected an array)'
);
}
);

it('should treat settings set to undefined or null as unset', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
configure({
loadImpl: async () =>
bootstrap({
// As a broker's JSON may spell unset fields.
pagesToInclude: null as unknown as RegExp[],
pagesToExclude: undefined,
sessionSampleRate: null as unknown as number,
telemetries: [
[
'http',
{
urlsToExclude: undefined,
urlsToInclude: null,
recordAllRequests: true
}
]
]
})
});
await sink.init();

const config = AwsRumCtor.mock.calls[0][3] as { telemetries: unknown[] };
// Strict: an explicit `undefined` or `null` would replace the SDK's default.
expect(config.telemetries).toStrictEqual([
['http', { recordAllRequests: true }]
]);
expect(config).not.toHaveProperty('pagesToInclude');
expect(config).not.toHaveProperty('pagesToExclude');
expect(config).not.toHaveProperty('sessionSampleRate');
expect(warn).not.toHaveBeenCalled();
});

it('should honour explicitly supplied app monitor settings', async () => {
configure({
loadImpl: async () =>
Expand Down
Loading
Loading