diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 1c0d2df..ad9a681 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -115,6 +115,7 @@ Everything each entry point exports, by role. | Setup | `provideUwtTelemetry`, `withLogging`, `withScenarios`, `withBIEvents`, `withRedaction`, `UwtTelemetryFeature`, `provideUwtTelemetrySink`, `UwtTelemetryLocalSinkMode` (`'broadcast' \| 'noop'`), `provideUwtTelemetryBroadcastHost`, `provideUwtTelemetryDestination` + `UwtTelemetryDestinationOptions` | | Configuration | `UWT_TELEMETRY_IDENTITY` + `UwtTelemetryIdentity`; `UWT_LOG_CONFIG` + `UwtLogConfig`; `UWT_SCENARIO_TELEMETRY_CONFIG` + `UwtScenarioTelemetryConfig`; `UWT_BI_TELEMETRY_CONFIG` + `UwtBITelemetryConfig`; `UWT_REDACT_CONFIG`; `UWT_DEFAULT_TELEMETRY_CONFIG` (every default) | | Scenarios | `UwtScenarioTelemetryService`, `UwtScenario`, `traceScenario` + `UwtTraceScenarioOptions`; models `UwtScenarioOptions`, `UwtScenarioOutcome`, `UwtScenarioStepDetail`, `UwtScenarioRecord`, `UwtScenarioStep`, `UwtScenarioStepEvent`, `UwtScenarioAggregate`, `UwtScenarioStatus`, `UwtScenarioStepStatus` | +| Backend correlation | `uwtScenarioHeaderInterceptor` + `UwtScenarioHeaderOptions`, `uwtScenarioContext`, `UWT_DEFAULT_SCENARIO_HEADER` (`'X-Uwt-Scenario-Id'`) | | BI events | `UwtBITelemetryService`; `UwtBIEvent`, `UwtBIEventDetail` (`scenarioId`, `feature`) | | Logging | `UwtLoggerService`, `UwtLogger`; `UwtLogDetail`, `UwtLogRecord`, `UwtLogLevel`, `UWT_LOG_LEVEL_ORDER`; `UWT_LOG_API_PROVIDER`, `UwtLogApiProvider`, `UwtLogQuery`; `UwtNoopLogApiProvider`, `UwtBroadcastLogApiProvider` | | Name registries | `UwtScenarioNames` / `UwtScenarioName`, `UwtScenarioSteps` / `UwtStepName`, `UwtBIEventNames` / `UwtBIEventName`, `UwtLoggerNames` / `UwtLoggerName` | @@ -123,7 +124,7 @@ Everything each entry point exports, by role. | Redaction, for custom sinks | `uwtRedactMetadata`, `uwtNormalizeError`, `uwtScrubString`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UwtPiiValuePattern`, `UWT_REDACTED` (`'[redacted]'`, the replacement value) | | Utilities | `uwtUuid()`, `uwtIsDebugEnabled(flag, name?)` + `UwtDebugFlag` (`'debugLogger' \| 'debugScenario' \| 'debugBI'`) | -**`@absaoss-cps/ngx-ui-watchtower/rum`:** `provideUwtTelemetryRumSink`, `UwtRumTelemetrySink`, +**`@absaoss-cps/ngx-ui-watchtower/rum`:** `provideUwtTelemetryRumSink` + `UwtRumSinkOptions` (`xRayExcludeUrls`), `UwtRumTelemetrySink`, `UWT_RUM_CREDENTIALS_PROVIDER`, `UwtRumCredentialsProvider`, `UwtRumBootstrap` (`{ config, credentials? }`), `UwtRumAppMonitorConfig`, `UwtRumCredentials` (`accessKeyId`, `secretAccessKey`, `sessionToken`, @@ -820,7 +821,7 @@ against documentation. | Performance measurements | ✅ via built-in `performance` telemetry + Web Vitals plugin | `WebVitalsPlugin`, `NavigationPlugin`, `ResourcePlugin` | | Flushing / buffering | ✅ `dispatch()`, `dispatchBeacon()`; `dispatchInterval` 5s, `batchLimit` 100 by default, both configurable via `UwtRumAppMonitorConfig` | `Orchestration.js` defaults | | Custom **metrics** | ❌ **not an SDK concept.** Emit events; define CloudWatch RUM _extended metrics_ server-side | -| Correlation identifiers | ⚠️ **no built-in scenario correlation.** X-Ray trace ids exist for HTTP; journey correlation is ours to design | +| Correlation identifiers | ⚠️ **no built-in scenario correlation.** X-Ray trace ids exist for HTTP, one per request; ours is `X-Uwt-Scenario-Id` (§8) | | Reading telemetry back from the browser | ❌ no export/read API | | Offline behaviour | ⚠️ events sit in the in-memory cache (`eventCacheSize` 1000 by default, configurable) and are lost on tab close; there is no persistent queue | | Web Worker execution | ❌ **not possible** — `SessionManager` reads `window.location.hostname`, `document.cookie` and `navigator.cookieEnabled`, none of which exist in a worker context | @@ -928,11 +929,69 @@ with the sink's own. Correlation runs only through holds no logger, so scenario telemetry never requires the logging stack to be configured at all — the two concerns meet only through the id. -**Backend correlation is by convention.** AWS performs no cross-system -correlation. Send `scenario.id` to your backend as a request header (e.g. -`X-Correlation-Id`) and have backend logs record it under the same name. The -library ships no HTTP interceptor for this — the header name and which hosts -may receive it are application decisions. +### Sending the scenario to your backend + +AWS performs no cross-system correlation, so the library sends the scenario's +id with the requests that belong to it, and the backend records it. + +```ts +providers: [ + provideHttpClient(withInterceptors([uwtScenarioHeaderInterceptor()])) +]; + +this.http + .get('/api/customers', { context: uwtScenarioContext(scenario) }) + .pipe(traceScenario(scenario)) + .subscribe(); +``` + +`uwtScenarioHeaderInterceptor` adds `X-Uwt-Scenario-Id: ` +(`UWT_DEFAULT_SCENARIO_HEADER`; rename it with `{ headerName }`) when: + +- the request is marked with `uwtScenarioContext(scenario)`. Requests are + marked one by one, because several scenarios can run at once and nothing + else can tell which one a request belongs to. Unmarked requests get no + header; +- the request is same-origin. A custom header on a cross-origin request + needs a CORS preflight the other server may not allow — the same reason + the RUM client sends its X-Ray header to the same origin only; +- telemetry is on, and the code runs in a browser; +- the request doesn't already carry the header. + +It judges "same-origin" by the URL as it sees it, so list it after any +interceptor that rewrites URLs, such as one that prefixes an API host. It +only adds the header. Errors, retries and cancellation reach the caller +unchanged, and settling the scenario stays with the caller or +`traceScenario`. A `retry()` sends the same id again. For `fetch`, set the +header yourself: `headers: { [UWT_DEFAULT_SCENARIO_HEADER]: scenario.id }`. + +**The X-Ray trace stays as it is.** The RUM client starts a new trace for +every request and sends it as `X-Amzn-Trace-Id`, so one scenario spans +several traces, and a retry gets its own. It does this for same-origin +requests, relative URLs (`/api/...`) included, and for a fragment's +requests too ([§13](#what-behaves-the-same-across-realms)). A same-origin +endpoint that redirects cross-origin (to S3, an IdP) passes both headers +on, so the target must allow them in CORS. Otherwise list the path in +`provideUwtTelemetryRumSink({ xRayExcludeUrls: [/\/files\//] })`, and don't mark that request with +a scenario. The two headers travel together; +the backend links them by adding the scenario id to its trace as an +annotation (an indexed, searchable label), and to its log lines: + +```ts +// Node + OpenTelemetry; the backend must continue the incoming X-Ray trace +const scenarioId = req.get('X-Uwt-Scenario-Id'); // untrusted: validate it +if (scenarioId && UUID.test(scenarioId)) { + const span = trace.getActiveSpan(); + span?.setAttribute('scenarioId', scenarioId); + span?.setAttribute('aws.xray.annotations', ['scenarioId']); + req.log = req.log.child({ scenarioId }); +} +``` + +The X-Ray filter `annotation.scenarioId = ""` then finds every trace of +the journey. Filterable annotation keys allow letters, digits and +underscores only, so use `scenarioId` in both places. Only the entry service needs to do this — the +trace header carries the trace on to the services it calls. ### Who is signed in @@ -1399,6 +1458,17 @@ async load(): Promise { } ``` +`config` mirrors the SDK and usually comes from the backend. Settings that +belong to the application's own code go to the provider instead, as +`UwtRumSinkOptions`. Today that is `xRayExcludeUrls`: same-origin URLs that +must not get the X-Ray header, such as an endpoint that redirects to another +origin which doesn't allow the header (see +[§8](#sending-the-scenario-to-your-backend)): + +```ts +provideUwtTelemetryRumSink({ xRayExcludeUrls: [/\/files\//] }); +``` + ### Per-logger levels `minLevel` is the floor for everything; `levels` overrides it by name, either @@ -1973,10 +2043,11 @@ That binding has a consequence for configuration. A bound fragment is embedded by id and routed to by a gateway, so the shell never sets its URL, and reading `location.search` inside one returns the host's query string rather than a fragment-specific one. A shell therefore cannot hand -per-fragment configuration down on a URL; it publishes it on the top-level -window instead, which every fragment can read because they all share the -shell's origin. See "Multiple tabs of the same composed page" below for -the pattern, applied there to a per-tab channel name. +per-fragment configuration down on a URL; it leaves it on its own `window` +instead, which every fragment can reach because they all share the shell's +origin. The library does this itself for what fragments need from it, the +channel name and the HTTP-tracing hook: see "Multiple tabs of the same +composed page" and "What behaves the same across realms" below. A separate realm means a separate Angular injector, so left alone **every realm builds its own copy of every telemetry service**: @@ -2045,11 +2116,11 @@ bootstrapApplication(FragmentRoot, { }); ``` -| Setting | Rule | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventNamespace` | **Must match** the shell, or the event types diverge and your CloudWatch queries split with them | -| Channel name | **Must match.** The default (`'ngx-ui-watchtower'`) needs no argument; if the shell passes a custom name, pass the same — `provideUwtTelemetrySink('broadcast', { channelName })` | -| `application` | **Should differ** per fragment. The forwarding sink stamps its realm's `application`, `environment` and `appVersion` onto every event, so you can tell fragments apart | +| Setting | Rule | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventNamespace` | **Must match** the shell, or the event types diverge and your CloudWatch queries split with them | +| Channel name | **Follows the shell.** A fragment nested in the shell's page uses the shell's channel by itself. Set `channelName` only for a realm outside the page (a popup), and then to the shell's name | +| `application` | **Should differ** per fragment. The forwarding sink stamps its realm's `application`, `environment` and `appVersion` onto every event, so you can tell fragments apart | A forwarded **error** has no metadata to carry that origin in — `recordError` takes none — so the RUM sink folds a differing origin into the @@ -2219,8 +2290,20 @@ Both records then join on `parentScenarioId`. shell reads correctly in a fragment despite the iframe having a later origin. The one limit: a moment from _before_ the fragment's realm existed gets clamped away and falls back to now. +- **HTTP requests.** The RUM client runs only in the shell, and patches only + the shell's `fetch` and `XMLHttpRequest`; a fragment has its own. So the + `'broadcast'` sink hands its realm's `window` to the shell's RUM sink at + startup (through the handle the shell leaves on its `window`, found by + walking `parent`), and the shell applies the client's own `fetch`/XHR plugins to + the fragment's globals. Fragment requests then get the same X-Ray header, + the same RUM HTTP events and the same session as the shell's. The fragment + keeps its own `fetch`, XHR and zone.js: requests aren't rerouted through + the shell's realm, which would break change detection in a zone-based + fragment. The shell lets go of the fragment's `window` when the iframe is + removed, or when its app is destroyed — not when the page only + enters the back/forward cache. - **Page identity.** The iframe's `location` is synced to the host's, so - the X-Ray same-origin regex and the route template both resolve against + the X-Ray same-origin check and the route template both resolve against the real URL. - **Debug flags.** One origin means one `localStorage`: a flag set once applies in every realm, and all of them write to the same console. @@ -2320,74 +2403,37 @@ providers: [ ]; ``` -Publish it as a global on the top-level window. Every fragment is same-origin -with its shell — `BroadcastChannel` would not work otherwise — so -`window.top` is reachable from inside any of them, plain iframe and Web -Fragment alike: - -```ts -declare global { - interface Window { - __uwtTelemetryChannel?: string; - } -} - -// shell — at module scope, before any fragment can read it. Browser-only, -// so the same module stays safe to evaluate under SSR. -if (typeof window !== 'undefined') { - window.__uwtTelemetryChannel = channelId; -} -``` - -Under SSR, generate the id inside that same browser check — `crypto` is only -a global from Node 19, and nothing on the server needs an id: -`provideUwtTelemetryBroadcastHost(undefined)` just keeps the default channel. - -```ts -// fragment -function shellChannelName(): string | undefined { - if (typeof window === 'undefined') { - return undefined; // server render - } - try { - return window.top?.__uwtTelemetryChannel; - } catch { - return undefined; // cross-origin `top` - } -} - -providers: [ - provideUwtTelemetry({ application: 'cart', environment: 'prod', version }), - provideUwtTelemetrySink('broadcast'), - { provide: UWT_BROADCAST_CHANNEL, useFactory: shellChannelName } -]; -``` - -Use `UWT_BROADCAST_CHANNEL` with `useFactory` rather than the `channelName` -option: the factory runs when the sink is first constructed, late enough -that a fragment booting before the shell publishes the id still picks it up. -`undefined` falls back to the default channel, so a fragment with no shell -behaves as before. Web Fragments' reframing virtualizes the DOM, `history` -and `location`, but not `top`, `BroadcastChannel` or storage — which is why -this works there. - -**Where the shell owns the fragment's URL** — a plain iframe — a query -parameter does the same job. The iframe must be same-origin with the shell, -like every fragment; a sourceless iframe (`about:blank`, `srcdoc`) qualifies -but has no URL to carry the parameter, so it reads the `top` global instead. - -```ts -// shell -iframe.src = `/fragment/?channel=${channelId}`; - -// fragment -const channelId = new URLSearchParams(window.location.search).get('channel'); -provideUwtTelemetrySink('broadcast', { channelName: channelId ?? undefined }); -``` - -That doesn't work under Web Fragments: a fragment is embedded by id and -routed by a gateway, so the shell has no URL to append to, and a _bound_ -fragment shares the host's `location`, so `location.search` is the shell's. +Fragments nested in the shell's page, plain iframes and Web Fragments +alike, pick that channel up by themselves, with nothing to add in them. The +host publishes it on a handle on its `window` +(`Symbol.for('ngx-ui-watchtower.host')`), and a fragment's `'broadcast'` +sink and log provider find it by walking up `window.parent`. Web Fragments' +reframing virtualizes the DOM, `history` and `location`, but not `parent` or +`BroadcastChannel`, which is why this works there. + +Under SSR, generate the id inside a browser check (`crypto` is only a global +from Node 19, and nothing on the server needs an id): +`provideUwtTelemetryBroadcastHost(undefined)` keeps the default channel. + +A fragment that sets its own channel (`UWT_BROADCAST_CHANNEL`, or +`channelName` on `provideUwtTelemetrySink('broadcast')`) keeps it. A +realm that forwards from outside the shell's page, such as a popup window +or another tab, can't see the shell's window, so it needs the channel set explicitly and +otherwise stays on the default. So does a fragment that starts before its +shell's host exists; Web Fragments mount after the shell has started. + +**Upgrading from the manual pattern.** Before, these docs had apps publish +`window.__uwtTelemetryChannel` in the shell and read it in each fragment +with `{ provide: UWT_BROADCAST_CHANNEL, useFactory: shellChannelName }`. +That keeps working, since an explicit channel always wins. Shells and fragments +usually deploy separately, so remove it in this order: + +1. Upgrade every fragment, then drop its `useFactory` provider. An upgraded + fragment finds the channel itself. +2. Only then remove `window.__uwtTelemetryChannel` from the shell. A + fragment still on an earlier version reads only that global; without it, + it falls back to the default channel, and its events stop reaching the + shell. With a channel per tab, each tab's election trivially picks itself, and a tab's fragments never hear another tab. A duplicated tab loads a fresh @@ -2405,6 +2451,14 @@ tabs, deliberately, since RUM models a session as a user's, not a tab's. - **Multiple tabs on one channel cross-attribute**, as above, until each tab has its own channel — the protocol carries no tab identifier to route back by. +- **Fragment HTTP tracing has the shell's limits, and two more.** As in the + shell, a request is traced only once the RUM client has loaded, and + fragment requests count against the one session's event budget (200 by + default). Code that captured the fragment's `fetch` before the shell + patched it is not traced. And when a fragment request fails at the + network level, its X-Ray segment is marked as a fault but has no + exception: the client tests the error with `instanceof Object`, which + fails for an error made in another realm. --- diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 954babc..e46f923 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -95,6 +95,18 @@ export class MyRumCredentials implements UwtRumCredentialsProvider { } ``` +### AWS X-Ray + +RUM adds an `X-Amzn-Trace-Id` header to your same-origin requests, including +those from embedded fragments, so AWS X-Ray can follow them into your +backend. If an endpoint redirects to another origin (e.g. a download that +302s to S3), the browser sends the header there too, and the request fails +unless that origin allows the header in CORS. Keep the header off such URLs: + +```ts +provideUwtTelemetryRumSink({ xRayExcludeUrls: [/\/files\//] }); +``` + ### Log backend ```ts @@ -238,6 +250,25 @@ For RxJS, `traceScenario` ends the scenario for you: this.api.fetchCustomers().pipe(traceScenario(scenario)).subscribe(); ``` +### Telling your backend + +To let backend logs and AWS X-Ray traces tie a request to its journey, send +the scenario id with it: + +```ts +provideHttpClient(withInterceptors([uwtScenarioHeaderInterceptor()])); + +this.http + .get('/api/customers', { context: uwtScenarioContext(scenario) }) + .pipe(traceScenario(scenario)) + .subscribe(); +``` + +Marked same-origin requests get an `X-Uwt-Scenario-Id` header (rename it +with `{ headerName }`); nothing else changes. List the interceptor after any +that rewrite URLs. Have your backend log the header, and add it to its X-Ray +trace as the `scenarioId` annotation. + ## BI events ```ts @@ -329,6 +360,16 @@ providers: [ ``` Fragment code doesn't change. Give each fragment its own `application` name. +A fragment's HTTP requests are traced by the shell's RUM client, the same as +the shell's own: same X-Ray header, same session. + +If the page can be open in several tabs, give each tab its own channel, so +one tab's fragments aren't recorded under another tab's page. Set it in the +shell only; nested fragments follow it: + +```ts +provideUwtTelemetryBroadcastHost(`ngx-ui-watchtower-${crypto.randomUUID()}`); +``` ## Seeing what is sent diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts index dddeb4f..02e81fc 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts @@ -99,9 +99,10 @@ export interface UwtRumAppMonitorConfig { /** * Built-in telemetry categories to enable. Defaults to errors, performance - * and same-origin HTTP. The SDK's own default also includes `'replay'` - * (session replay via `rrweb`); this library does not enable it by - * default. + * and HTTP, with the X-Ray header on same-origin requests (relative URLs + * included). The `http` entry also covers fragment realms that forward to + * this one. The SDK's own default also includes `'replay'` (session replay + * via `rrweb`); this library does not enable it by default. */ telemetries?: (string | (string | object)[])[]; diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.spec.ts new file mode 100644 index 0000000..1d32522 --- /dev/null +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.spec.ts @@ -0,0 +1,259 @@ +import { AwsRum, FetchPlugin, XhrPlugin } from 'aws-rum-web'; +import { + UWT_HOST_KEY, + UwtHostHandle, + UwtHttpRealmHost, + uwtHostHttpRealms +} from './uwt-rum-realms'; + +/** The SDK's plugin context, as the real client would pass it. */ +function pluginContext() { + return { + config: { enableXRay: true, enableW3CTraceId: false }, + getSession: () => ({ record: true }), + record: jest.fn() + }; +} + +/** A `fetch` init, as far as the plugin touches it. */ +type Init = { headers?: Record }; + +class FakeXhr { + readonly headers: Record = {}; + + open(..._args: unknown[]): void {} + + send(): void {} + + setRequestHeader(name: string, value: string): void { + this.headers[name] = value; + } + + addEventListener(): void {} +} + +/** A fragment realm: its own `fetch`, its own `XMLHttpRequest`. */ +function fragmentRealm() { + const fetch = jest.fn(() => + Promise.resolve({ status: 200, ok: true, headers: new Headers() }) + ); + class Xhr extends FakeXhr {} + const realm = Object.assign(new EventTarget(), { + fetch, + XMLHttpRequest: Xhr + }); + return { + window: realm as unknown as Window, + nativeFetch: fetch, + nativeOpen: Xhr.prototype.open, + fetch: (url: string, init: Init) => + (realm.fetch as (url: string, init: Init) => Promise)(url, init), + xhr: () => new realm.XMLHttpRequest(), + hide: (persisted = false) => + realm.dispatchEvent(Object.assign(new Event('pagehide'), { persisted })), + get patchedFetch() { + return realm.fetch !== fetch; + } + }; +} + +const sdk = { FetchPlugin, XhrPlugin }; +const http = ['http', { addXRayTraceIdHeader: [/^\//] }]; + +describe('uwtHostHttpRealms', () => { + let host: Window; + let realms: UwtHttpRealmHost; + let context: ReturnType; + let added: (FetchPlugin | XhrPlugin)[]; + let awsRum: AwsRum; + + const handle = () => + (host as unknown as Record)[ + UWT_HOST_KEY + ]; + const join = (realm: Window) => { + const traceHttp = handle()?.traceHttp; + if (!traceHttp) { + throw new Error('no traceHttp on the handle'); + } + return traceHttp(realm); + }; + + beforeEach(() => { + host = {} as Window; + realms = uwtHostHttpRealms(host); + context = pluginContext(); + added = []; + awsRum = { + addPlugin: (plugin: FetchPlugin | XhrPlugin) => { + added.push(plugin); + plugin.load(context as never); + } + } as unknown as AwsRum; + }); + + afterEach(() => { + realms.stop(); + jest.restoreAllMocks(); + }); + + it('traces a realm that joined before the client started', async () => { + const fragment = fragmentRealm(); + join(fragment.window); + expect(fragment.patchedFetch).toBe(false); + + realms.start(sdk, awsRum, ['errors', http]); + const init: Init = {}; + await fragment.fetch('/api/customers', init); + + expect(init.headers?.['X-Amzn-Trace-Id']).toMatch( + /^Root=1-[0-9a-f]{8}-[0-9a-f]{24};Parent=[0-9a-f]{16};Sampled=1$/ + ); + expect(fragment.nativeFetch).toHaveBeenCalledTimes(1); + expect(context.record).toHaveBeenCalledWith( + 'com.amazon.rum.xray_trace_event', + expect.anything() + ); + }); + + it('traces a realm that joins after the client started', async () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + + const init: Init = {}; + await fragment.fetch('/api/customers', init); + + expect(init.headers).toHaveProperty('X-Amzn-Trace-Id'); + }); + + it("patches the realm's own XMLHttpRequest", () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + + const xhr = fragment.xhr(); + xhr.open('GET', '/api/customers'); + xhr.send(); + + expect(xhr.headers['X-Amzn-Trace-Id']).toMatch(/^Root=1-/); + }); + + it('gives every realm its own plugin ids', () => { + realms.start(sdk, awsRum, [http]); + join(fragmentRealm().window); + join(fragmentRealm().window); + + const ids = added.map((plugin) => plugin.getPluginId()); + expect(new Set(ids).size).toBe(4); + expect(ids).not.toContain(new FetchPlugin().getPluginId()); + }); + + it('instruments a realm once, however often it joins', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + join(fragment.window); + + expect(added).toHaveLength(2); + }); + + it('unpatches the realm and lets go of it on leave', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + const leave = join(fragment.window); + + leave(); + + expect(fragment.patchedFetch).toBe(false); + expect(fragment.xhr().open).toBe(fragment.nativeOpen); + for (const plugin of added) { + expect((plugin as unknown as { patches: unknown[] }).patches).toEqual([]); + } + }); + + it('releases a realm whose page is hidden, e.g. a removed iframe', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + + fragment.hide(); + + expect(fragment.patchedFetch).toBe(false); + }); + + it('keeps a realm traced while the page is only cached (back/forward)', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + + fragment.hide(true); + + expect(fragment.patchedFetch).toBe(true); + }); + + it('never traces a realm whose join failed', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + // Not an EventTarget, so the pagehide listener can't be added. + const notAWindow = { fetch: jest.fn() } as unknown as Window; + const nativeFetch = (notAWindow as unknown as { fetch: unknown }).fetch; + join(notAWindow); + + realms.start(sdk, awsRum, [http]); + + expect((notAWindow as unknown as { fetch: unknown }).fetch).toBe( + nativeFetch + ); + }); + + it('releases a Web Fragments realm, whose own addEventListener is rerouted', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + // As Web Fragments does: listeners added through the fragment's own + // addEventListener go to the shell's window instead. + fragment.window.addEventListener = () => undefined; + join(fragment.window); + + fragment.hide(); + + expect(fragment.patchedFetch).toBe(false); + }); + + it('releases every realm and takes traceHttp off the handle on stop', () => { + realms.start(sdk, awsRum, [http]); + const fragment = fragmentRealm(); + join(fragment.window); + + realms.stop(); + + expect(fragment.patchedFetch).toBe(false); + expect(handle()?.traceHttp).toBeUndefined(); + }); + + it("leaves the broadcast host's channel name on the handle on stop", () => { + const shared = handle(); + if (shared) { + shared.channelName = 'shell-channel'; + } + + realms.stop(); + + expect(handle()?.channelName).toBe('shell-channel'); + }); + + it('traces nothing when the client has no http telemetry', () => { + realms.start(sdk, awsRum, ['errors', 'performance']); + const fragment = fragmentRealm(); + join(fragment.window); + + expect(added).toHaveLength(0); + expect(fragment.patchedFetch).toBe(false); + }); + + it('reads the http entry the way the client does', () => { + realms.start(sdk, awsRum, ['HTTP']); + join(fragmentRealm().window); + + expect(added).toHaveLength(2); + }); +}); diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.ts new file mode 100644 index 0000000..fe4e316 --- /dev/null +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-realms/uwt-rum-realms.ts @@ -0,0 +1,239 @@ +import type { AwsRum, FetchPlugin, XhrPlugin } from 'aws-rum-web'; +import { + uwtSafe, + uwtSafeVoid +} from '../uwt-rum-internal.util/uwt-rum-internal.util'; + +/** + * Where a shell leaves its handle for the frames nested in it, on its own + * `window`. + * + * Must match the key in the main entry's `uwt-host-handle.util.ts`; not + * imported from there, for the reason given in `uwt-rum-internal.util.ts`. + */ +export const UWT_HOST_KEY = Symbol.for('ngx-ui-watchtower.host'); + +/** + * The shell's handle, as the main entry's `uwt-host-handle.util.ts` defines + * it. This sink owns only `traceHttp`; the broadcast host owns `channelName`. + */ +export interface UwtHostHandle { + channelName?: string; + /** Traces `realm`'s HTTP requests; returns the call that stops it. */ + traceHttp?(realm: Window): () => void; +} + +/** The part of the `aws-rum-web` module the host needs. */ +export interface UwtRumHttpPlugins { + FetchPlugin: typeof FetchPlugin; + XhrPlugin: typeof XhrPlugin; +} + +/** + * Traces the HTTP requests of same-origin fragment realms with the RUM + * client's own `fetch`/XHR plugins, so a fragment's requests get the same + * X-Ray header and HTTP events as the host's own. + */ +export interface UwtHttpRealmHost { + /** + * Starts tracing realms that joined and realms that join later. A no-op + * unless `telemetries` enables `http`, matching what the host traces. + */ + start( + sdk: UwtRumHttpPlugins, + awsRum: AwsRum, + telemetries: readonly unknown[] + ): void; + + /** Stops tracing every realm and takes `traceHttp` off the handle. */ + stop(): void; +} + +type HttpConfig = ConstructorParameters[0]; + +/** + * This realm's own listener methods, called on a fragment's window. Web + * Fragments reroutes listeners added through a fragment's own + * `addEventListener` to the shell's window (all but `load`, `popstate`, + * `beforeunload` and `unload`), so a `pagehide` added that way would never + * fire for the fragment. Not `unload` instead: it takes the page out of the + * back/forward cache. + */ +const { addEventListener: listen, removeEventListener: unlisten } = + EventTarget.prototype; + +interface JoinedRealm { + onPageHide: (event: Event) => void; + release?: () => void; +} + +/** + * Puts `traceHttp` on `host`'s handle and returns its controls. + * + * Realms can join before {@link UwtHttpRealmHost.start}: fragments mount + * while the host is still fetching credentials and loading the SDK. + */ +export function uwtHostHttpRealms(host: Window): UwtHttpRealmHost { + const realms = new Map(); + let instrument: ((realm: Window) => () => void) | undefined; + + const traced = (realm: Window) => + uwtSafe('rum.realms.instrument', () => instrument?.(realm), undefined); + + const leave = (realm: Window) => + uwtSafeVoid('rum.realms.leave', () => { + const joined = realms.get(realm); + if (!joined) { + return; + } + realms.delete(realm); + unlisten.call(realm, 'pagehide', joined.onPageHide); + joined.release?.(); + }); + + const traceHttp = (realm: Window) => + uwtSafe( + 'rum.realms.join', + () => { + if (!realms.has(realm)) { + // A removed fragment iframe fires pagehide even if its app is + // never destroyed; release it then, so no detached window is + // kept. Not when the page only enters the back/forward cache: + // the realm comes back alive, and nothing would join it again. + const onPageHide = (event: Event) => { + if (!(event as PageTransitionEvent).persisted) { + leave(realm); + } + }; + listen.call(realm, 'pagehide', onPageHide); + const joined: JoinedRealm = { onPageHide }; + realms.set(realm, joined); + joined.release = traced(realm); + } + return () => leave(realm); + }, + () => undefined + ); + const slots = host as unknown as Record; + const handle = (slots[UWT_HOST_KEY] ??= {}); + handle.traceHttp = traceHttp; + + return { + start(sdk, awsRum, telemetries) { + const config = httpTelemetryConfig(telemetries); + if (!config) { + return; + } + let plugins: ReturnType | undefined; + let count = 0; + instrument = (realm) => { + plugins ??= realmPlugins(sdk); + const id = `realm-${++count}`; + const instances = [ + new plugins.RealmFetchPlugin(realm, config, id), + new plugins.RealmXhrPlugin(realm, config, id) + ]; + // addPlugin loads each plugin, which patches the realm's globals. + instances.forEach((plugin) => awsRum.addPlugin(plugin)); + return () => instances.forEach((plugin) => plugin.release()); + }; + for (const [realm, joined] of realms) { + joined.release ??= traced(realm); + } + }, + + stop() { + instrument = undefined; + [...realms.keys()].forEach(leave); + if (handle.traceHttp === traceHttp) { + delete handle.traceHttp; + } + } + }; +} + +/** + * The `http` entry's config, as the client reads it (`'http'` or + * `['http', config]`, case-insensitive), or `undefined` when there is none. + */ +function httpTelemetryConfig( + telemetries: readonly unknown[] +): HttpConfig | undefined { + for (const entry of telemetries) { + const [name, config] = Array.isArray(entry) ? entry : [entry]; + if (typeof name === 'string' && name.toLowerCase() === 'http') { + return (config as HttpConfig | undefined) ?? {}; + } + } + return undefined; +} + +/** + * The client's `fetch`/XHR plugins, aimed at another realm's globals. + * + * Only the patch target and the plugin id change; header generation and + * recording stay the client's own. Built from the dynamically imported + * module, since the classes don't exist until the SDK loads. The client + * can't remove a plugin, so a released one stays registered, emptied: no + * patches, no realm. + */ +function realmPlugins(sdk: UwtRumHttpPlugins) { + class RealmFetchPlugin extends sdk.FetchPlugin { + constructor( + private realm: Window | undefined, + config: HttpConfig, + private readonly realmId: string + ) { + super(config); + } + + override getPluginId(): string { + return `${super.getPluginId()}.${this.realmId}`; + } + + protected override get patches() { + const realm = this.realm; + return realm + ? super.patches.map((patch) => ({ ...patch, nodule: realm })) + : []; + } + + /** Unpatches and lets go of the realm. */ + release(): void { + this.disable(); + this.realm = undefined; + } + } + + class RealmXhrPlugin extends sdk.XhrPlugin { + constructor( + private realm: Window | undefined, + config: HttpConfig, + private readonly realmId: string + ) { + super(config); + } + + override getPluginId(): string { + return `${super.getPluginId()}.${this.realmId}`; + } + + protected override get patches() { + const realm = this.realm as (Window & typeof globalThis) | undefined; + return realm + ? super.patches.map((patch) => ({ + ...patch, + nodule: realm.XMLHttpRequest.prototype + })) + : []; + } + + /** Unpatches and lets go of the realm. */ + release(): void { + this.disable(); + this.realm = undefined; + } + } + + return { RealmFetchPlugin, RealmXhrPlugin }; +} diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-sink-options/uwt-rum-sink-options.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-sink-options/uwt-rum-sink-options.ts new file mode 100644 index 0000000..0658b07 --- /dev/null +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-sink-options/uwt-rum-sink-options.ts @@ -0,0 +1,25 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Options for `provideUwtTelemetryRumSink()`: set in the application's own + * code, unlike `UwtRumAppMonitorConfig`, which mirrors the SDK and + * usually arrives from the backend. + * + * @group Interfaces + */ +export interface UwtRumSinkOptions { + /** + * Same-origin URLs that must not get the X-Ray header, matched against + * the full resolved URL, e.g. `[/\/files\//]`. Use it for an endpoint that + * redirects to another origin which doesn't allow the header in CORS: the + * browser passes the header on with the redirect, and the request fails. + * Covers fragment realms too. Applies to the default `telemetries` only; + * with your own, your `addXRayTraceIdHeader` decides. Default `[]`. + */ + xRayExcludeUrls?: RegExp[]; +} + +/** Carries {@link UwtRumSinkOptions} to the sink. Internal. */ +export const UWT_RUM_SINK_OPTIONS = new InjectionToken( + 'UWT_RUM_SINK_OPTIONS' +); diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts index 2893eed..ade9f73 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts @@ -12,6 +12,10 @@ import { UWT_RUM_CREDENTIALS_PROVIDER, UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UWT_RUM_SINK_OPTIONS, + UwtRumSinkOptions +} from '../uwt-rum-sink-options/uwt-rum-sink-options'; import { UwtRumTelemetrySink } from './uwt-rum-telemetry.sink'; /** Stand-in for the real `AwsRum` client. */ @@ -27,7 +31,8 @@ const awsRumInstance = { getSessionId: jest.fn(() => 'rum-session-1'), dispatch: jest.fn(), dispatchBeacon: jest.fn(), - disable: jest.fn() + disable: jest.fn(), + addPlugin: jest.fn() }; // Typed on the real `new AwsRum(applicationId, applicationVersion, region, @@ -46,6 +51,20 @@ jest.mock( () => ({ AwsRum: function (this: unknown, ...args: unknown[]) { return AwsRumCtor(...(args as Parameters)); + }, + FetchPlugin: class { + getPluginId() { + return 'fetch'; + } + + disable() {} + }, + XhrPlugin: class { + getPluginId() { + return 'xhr'; + } + + disable() {} } }), { virtual: true } @@ -71,6 +90,19 @@ function bootstrap( }; } +/** The X-Ray header matchers the client was built with. */ +function xRayMatchers() { + const config = AwsRumCtor.mock.calls[0][3] as { + telemetries: [string, string, [string, { addXRayTraceIdHeader: RegExp[] }]]; + }; + const matchers = config.telemetries[2][1].addXRayTraceIdHeader; + // The SDK adds the header when any matcher matches the URL as passed. + return { + matchers, + traced: (url: string) => matchers.some((m) => m.test(url)) + }; +} + describe('UwtRumTelemetrySink', () => { let sink: UwtRumTelemetrySink; let load: jest.Mock; @@ -79,6 +111,7 @@ describe('UwtRumTelemetrySink', () => { platformId?: object; enabled?: boolean; loadImpl?: () => Promise; + sinkOptions?: UwtRumSinkOptions; }): void { load = jest.fn(options?.loadImpl ?? (async () => bootstrap())); @@ -99,6 +132,9 @@ describe('UwtRumTelemetrySink', () => { }, { provide: PLATFORM_ID, useValue: options?.platformId ?? 'browser' }, { provide: UWT_RUM_CREDENTIALS_PROVIDER, useValue: { load } }, + ...(options?.sinkOptions + ? [{ provide: UWT_RUM_SINK_OPTIONS, useValue: options.sinkOptions }] + : []), { provide: UWT_TELEMETRY_ENABLED, useValue: options?.enabled ?? true }, UwtRumTelemetrySink ] @@ -155,19 +191,55 @@ describe('UwtRumTelemetrySink', () => { it('should scope the X-Ray trace header to the exact origin, not a look-alike host', async () => { await sink.init(); - const config = AwsRumCtor.mock.calls[0][3] as { - telemetries: [ - string, - string, - [string, { addXRayTraceIdHeader: RegExp[] }] - ]; - }; - const sameOrigin = config.telemetries[2][1].addXRayTraceIdHeader[0]; + const { traced } = xRayMatchers(); const origin = window.location.origin; - expect(sameOrigin.test(`${origin}/api/customers`)).toBe(true); - expect(sameOrigin.test(origin)).toBe(true); - expect(sameOrigin.test(`${origin}.attacker.example/api`)).toBe(false); + expect(traced(`${origin}/api/customers`)).toBe(true); + expect(traced(origin)).toBe(true); + expect(traced(`${origin}.attacker.example/api`)).toBe(false); + expect(traced('https://other.example/api')).toBe(false); + expect(traced('//other.example/api')).toBe(false); + expect(traced('data:text/plain,x')).toBe(false); + }); + + it('should resolve a URL the way the browser does before comparing origins', async () => { + await sink.init(); + + const { matchers, traced } = xRayMatchers(); + + // Each of these looks relative but resolves to another host. + expect(traced('/\\api.other.example/x')).toBe(false); + expect(traced('\\\\api.other.example/x')).toBe(false); + expect(traced(' https://api.other.example/x')).toBe(false); + expect(traced('/\t/api.other.example/x')).toBe(false); + expect(traced('http://[bad')).toBe(false); + // Anything bypassing `test` gets a pattern that matches nothing. + expect(RegExp.prototype.exec.call(matchers[0], '/api')).toBeNull(); + }); + + it('should treat a relative URL as same-origin, as HttpClient passes it unresolved', async () => { + await sink.init(); + + const { traced } = xRayMatchers(); + + expect(traced('/api/customers')).toBe(true); + expect(traced('api/customers')).toBe(true); + expect(traced('?page=2')).toBe(true); + }); + + it('should leave out URLs listed in xRayExcludeUrls, relative or absolute', async () => { + configure({ + // `g` on purpose: it must not skip every other request. + sinkOptions: { xRayExcludeUrls: [/\/files\//g] } + }); + await sink.init(); + + const { traced } = xRayMatchers(); + + expect(traced('/files/123')).toBe(false); + expect(traced('/files/124')).toBe(false); + expect(traced(`${window.location.origin}/files/123`)).toBe(false); + expect(traced('/api/customers')).toBe(true); }); it('should honour explicitly supplied app monitor settings', async () => { @@ -628,6 +700,82 @@ describe('UwtRumTelemetrySink', () => { }); }); + describe('fragment realms', () => { + const KEY = Symbol.for('ngx-ui-watchtower.host'); + const traceHttp = () => + ( + window as unknown as Record< + symbol, + { traceHttp?(realm: Window): () => void } | undefined + > + )[KEY]?.traceHttp; + const join = (realm: Window) => { + const trace = traceHttp(); + if (!trace) { + throw new Error('no traceHttp on the handle'); + } + return trace(realm); + }; + const realm = () => + Object.assign(new EventTarget(), { + parent: window + }) as unknown as Window; + const addedIds = () => + awsRumInstance.addPlugin.mock.calls.map(([plugin]) => + (plugin as { getPluginId(): string }).getPluginId() + ); + + it('should trace a realm that joined before the client started', async () => { + join(realm()); + expect(awsRumInstance.addPlugin).not.toHaveBeenCalled(); + + await sink.init(); + + expect(addedIds()).toEqual(['fetch.realm-1', 'xhr.realm-1']); + }); + + it('should trace a realm that joins after the client started', async () => { + await sink.init(); + join(realm()); + + expect(addedIds()).toEqual(['fetch.realm-1', 'xhr.realm-1']); + }); + + it('should trace no realm when the app monitor has no http telemetry', async () => { + configure({ + loadImpl: async () => bootstrap({ telemetries: ['errors'] }) + }); + await sink.init(); + join(realm()); + + expect(awsRumInstance.addPlugin).not.toHaveBeenCalled(); + }); + + it('should trace no realm while the client is switched off', async () => { + configure({ + loadImpl: async () => bootstrap({ enableRumClient: false }) + }); + await sink.init(); + join(realm()); + + expect(awsRumInstance.addPlugin).not.toHaveBeenCalled(); + }); + + it('should take traceHttp off the handle on destroy', () => { + expect(traceHttp()).toBeDefined(); + + sink.ngOnDestroy(); + + expect(traceHttp()).toBeUndefined(); + }); + + it('should offer no traceHttp while telemetry is off', () => { + configure({ enabled: false }); + + expect(traceHttp()).toBeUndefined(); + }); + }); + describe('recording', () => { it('should forward events to the client once initialized', async () => { await sink.init(); diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts index 4a1f575..9e4b5e4 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts @@ -23,6 +23,8 @@ import { UwtRumBootstrap, UwtRumCredentials } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { UWT_RUM_SINK_OPTIONS } from '../uwt-rum-sink-options/uwt-rum-sink-options'; +import { uwtHostHttpRealms } from '../uwt-rum-realms/uwt-rum-realms'; /** Refresh credentials this long before they expire. */ const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; @@ -117,6 +119,7 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { private readonly config = inject(UWT_TELEMETRY_IDENTITY); private readonly redact = inject(UWT_REDACT_CONFIG); private readonly credentialsProvider = inject(UWT_RUM_CREDENTIALS_PROVIDER); + private readonly options = inject(UWT_RUM_SINK_OPTIONS, { optional: true }); private readonly document = inject(DOCUMENT); /** * False on the server and while telemetry is off: the client never starts — @@ -126,6 +129,18 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { uwtIsBrowser() && (inject(UWT_TELEMETRY_ENABLED, { optional: true }) ?? true); + /** Traces fragment realms' HTTP requests like this realm's own. */ + private readonly realms = this.canStart + ? uwtSafe( + 'rum.realms', + () => + this.document.defaultView + ? uwtHostHttpRealms(this.document.defaultView) + : undefined, + undefined + ) + : undefined; + private awsRum: AwsRum | null = null; private refreshTimer: ReturnType | null = null; private buffer: BufferedItem[] = []; @@ -183,7 +198,7 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { return; } - const { AwsRum } = await import('aws-rum-web'); + const { AwsRum, FetchPlugin, XhrPlugin } = await import('aws-rum-web'); if (this.destroyed) { return; } @@ -191,12 +206,14 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { const sessionCookie = sessionCookieName(bootstrap); const hadSession = this.hasCookie(sessionCookie); - this.awsRum = new AwsRum( + const rumConfig = this.buildRumConfig(bootstrap); + const awsRum = new AwsRum( bootstrap.config.applicationId, bootstrap.config.applicationVersion, bootstrap.config.region, - this.buildRumConfig(bootstrap) + rumConfig ); + this.awsRum = awsRum; this.marksSessionStarts = bootstrap.config.allowCookies !== false && @@ -217,6 +234,17 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { } this.applyCredentials(bootstrap.credentials); this.replayBuffer(); + // Not while the client is switched off: it has disabled its own + // plugins, and a fragment's would otherwise enable themselves on load. + if (bootstrap.config.enableRumClient !== false) { + uwtSafeVoid('rum.realms.start', () => + this.realms?.start( + { FetchPlugin, XhrPlugin }, + awsRum, + rumConfig.telemetries ?? [] + ) + ); + } } catch (error) { this.disableAwsRum(); this.buffer = []; @@ -388,6 +416,7 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { */ private disableAwsRum(): void { uwtSafeVoid('rum.disable', () => this.awsRum?.disable()); + uwtSafeVoid('rum.realms.stop', () => this.realms?.stop()); this.awsRum = null; this.disabled = true; } @@ -405,16 +434,33 @@ export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { */ private buildRumConfig(bootstrap: UwtRumBootstrap): AwsRumConfig { const { config } = bootstrap; - const origin = this.document.defaultView?.location?.origin ?? ''; + const document = this.document; + const origin = document.defaultView?.origin; // X-Ray trace header only on same-origin requests; a cross-origin - // request with an unexpected header fails CORS preflight. Anchored at - // both ends (with a path/query/fragment/end-of-string lookahead) so - // `https://example.com` doesn't also match a look-alike host like - // `https://example.com.attacker.example`. - const sameOrigin = new RegExp( - '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' - ); + // request with an unexpected header fails CORS preflight. The client + // tests each URL as the caller passed it — Angular's HttpClient passes + // `/api/...` unresolved — so resolve it the way the browser will + // (backslashes, stray whitespace and all) before comparing origins. A + // RegExp, as the client's config expects, whose `test` does that; the + // pattern itself never matches, so anything that bypasses `test` adds + // no header rather than adding it everywhere. + const excluded = this.options?.xRayExcludeUrls ?? []; + const sameOrigin = Object.assign(/(?!)/, { + test: (url: string) => { + try { + const resolved = new URL(url, document.baseURI); + return ( + resolved.origin === origin && + // search(), not test(): a `g` pattern's test() carries lastIndex + // over, and would skip every other request. + !excluded.some((pattern) => resolved.href.search(pattern) >= 0) + ); + } catch { + return false; + } + } + }); return { allowCookies: config.allowCookies ?? true, diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.spec.ts index e88f3a2..a5d1d38 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.spec.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.spec.ts @@ -10,6 +10,10 @@ import { UwtRumBootstrap, UwtRumCredentialsProvider } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UWT_RUM_SINK_OPTIONS, + UwtRumSinkOptions +} from '../uwt-rum-sink-options/uwt-rum-sink-options'; import { UwtRumTelemetrySink } from '../uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; import { provideUwtTelemetryRumSink } from './uwt-rum.providers'; @@ -24,7 +28,7 @@ class StubCredentialsProvider implements UwtRumCredentialsProvider { } describe('provideUwtTelemetryRumSink', () => { - function configure(): void { + function configure(options?: UwtRumSinkOptions): void { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ @@ -33,7 +37,7 @@ describe('provideUwtTelemetryRumSink', () => { environment: 'prod', version: '1.0.0' }), - provideUwtTelemetryRumSink(), + provideUwtTelemetryRumSink(options), { provide: UWT_RUM_CREDENTIALS_PROVIDER, useClass: StubCredentialsProvider @@ -44,6 +48,13 @@ describe('provideUwtTelemetryRumSink', () => { beforeEach(() => configure()); + it('should hand its options to the sink', () => { + const options = { xRayExcludeUrls: [/\/files\//] }; + configure(options); + + expect(TestBed.inject(UWT_RUM_SINK_OPTIONS)).toBe(options); + }); + it('should replace the default sink with the RUM sink', () => { expect(TestBed.inject(UwtTelemetrySink)).toBeInstanceOf( UwtRumTelemetrySink diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.ts index b677265..ec296d1 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum.providers/uwt-rum.providers.ts @@ -1,5 +1,9 @@ -import { EnvironmentProviders } from '@angular/core'; +import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; import { provideUwtTelemetryDestination } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_SINK_OPTIONS, + UwtRumSinkOptions +} from '../uwt-rum-sink-options/uwt-rum-sink-options'; import { UwtRumTelemetrySink } from '../uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; /** @@ -26,18 +30,27 @@ import { UwtRumTelemetrySink } from '../uwt-rum-telemetry.sink/uwt-rum-telemetry * provideUwtTelemetryRumSink(), * { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: CartRumCredentials } * ] + * + * // keep the X-Ray header off an endpoint that redirects to another origin + * provideUwtTelemetryRumSink({ xRayExcludeUrls: [/\/files\//] }); * ``` * + * @param options optional settings, e.g. URLs to keep the X-Ray header off * @returns providers wiring the RUM sink * * @group Utils */ -export function provideUwtTelemetryRumSink(): EnvironmentProviders { - return provideUwtTelemetryDestination(UwtRumTelemetrySink, { - // Not awaited, so a slow or hung credential broker doesn't delay first - // paint. - init: (sink) => { - sink.init().catch(() => undefined); - } - }); +export function provideUwtTelemetryRumSink( + options: UwtRumSinkOptions = {} +): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: UWT_RUM_SINK_OPTIONS, useValue: options }, + provideUwtTelemetryDestination(UwtRumTelemetrySink, { + // Not awaited, so a slow or hung credential broker doesn't delay first + // paint. + init: (sink) => { + sink.init().catch(() => undefined); + } + }) + ]); } diff --git a/projects/ngx-ui-watchtower/rum/src/public-api.ts b/projects/ngx-ui-watchtower/rum/src/public-api.ts index 3a66a17..32977cf 100644 --- a/projects/ngx-ui-watchtower/rum/src/public-api.ts +++ b/projects/ngx-ui-watchtower/rum/src/public-api.ts @@ -15,4 +15,5 @@ export type { UwtRumCredentials, UwtRumCredentialsProvider } from './lib/uwt-rum-credentials/uwt-rum-credentials'; +export type { UwtRumSinkOptions } from './lib/uwt-rum-sink-options/uwt-rum-sink-options'; export { UwtRumTelemetrySink } from './lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index 6e5985a..77a4a5d 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -50,7 +50,7 @@ export class UwtBroadcastLogApiProvider implements UwtLogApiProvider, OnDestroy { private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastLogApi'); + uwtConnectBroadcastChannel('broadcastLogApi', { followShellChannel: true }); private readonly pending = new Map(); diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 298d081..0286743 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -393,6 +393,14 @@ export function provideUwtTelemetrySink( case 'broadcast': return makeEnvironmentProviders([ provideUwtTelemetryDestination(UwtBroadcastTelemetrySink), + // Constructed eagerly, so this realm hands its HTTP requests to the + // host's RUM client before making the first one — unless telemetry + // is off. + provideAppInitializer(() => { + if (uwtTelemetryEnabled()) { + inject(UwtBroadcastTelemetrySink); + } + }), UwtBroadcastLogApiProvider, { provide: UWT_LOG_API_PROVIDER, diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.spec.ts new file mode 100644 index 0000000..9e1b39d --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.spec.ts @@ -0,0 +1,283 @@ +import { + HttpClient, + HttpContextToken, + HttpErrorResponse, + provideHttpClient, + withInterceptors +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting +} from '@angular/common/http/testing'; +import { DOCUMENT } from '@angular/common'; +import { PLATFORM_ID, Provider } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { retry } from 'rxjs'; +import { UWT_TELEMETRY_ENABLED } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenario } from '../uwt-scenario/uwt-scenario'; +import { traceScenario } from '../uwt-scenario-operators/uwt-scenario-operators'; +import { + UwtScenarioHeaderOptions, + uwtScenarioContext, + uwtScenarioHeaderInterceptor +} from './uwt-scenario-http'; + +function scenario(id: string): jest.Mocked { + return { + id, + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; +} + +describe('uwtScenarioHeaderInterceptor', () => { + let http: HttpClient; + let backend: HttpTestingController; + + function configure( + options?: UwtScenarioHeaderOptions, + ...providers: Provider[] + ): void { + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideHttpClient( + withInterceptors([uwtScenarioHeaderInterceptor(options)]) + ), + provideHttpClientTesting(), + ...providers + ] + }); + http = TestBed.inject(HttpClient); + backend = TestBed.inject(HttpTestingController); + } + + afterEach(() => { + backend.verify(); + jest.restoreAllMocks(); + }); + + describe('with defaults', () => { + beforeEach(() => configure()); + + it('sends the scenario id on a same-origin request', () => { + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + req.flush([]); + }); + + it('sends it on an absolute same-origin URL', () => { + const url = `${document.location.origin}/api/customers`; + http + .get(url, { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne(url); + expect(req.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + req.flush([]); + }); + + it('sends nothing on a request without a scenario', () => { + http.get('/api/customers').subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + req.flush([]); + }); + + it('sends nothing on a cross-origin request', () => { + http + .get('https://api.example.com/customers', { + context: uwtScenarioContext(scenario('s-1')) + }) + .subscribe(); + + const req = backend.expectOne('https://api.example.com/customers'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + req.flush([]); + }); + + it('keeps a header the request already carries', () => { + http + .get('/api/customers', { + context: uwtScenarioContext(scenario('s-1')), + headers: { 'x-uwt-scenario-id': 'set-by-app' } + }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.getAll('X-Uwt-Scenario-Id')).toEqual([ + 'set-by-app' + ]); + req.flush([]); + }); + + it('takes other context tokens alongside the scenario', () => { + const OTHER = new HttpContextToken(() => 'default'); + http + .get('/api/customers', { + context: uwtScenarioContext(scenario('s-1')).set(OTHER, 'kept') + }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.context.get(OTHER)).toBe('kept'); + expect(req.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + req.flush([]); + }); + + it('gives every call its own context, so none leaks into another request', () => { + const s = scenario('s-1'); + + expect(uwtScenarioContext(s)).not.toBe(uwtScenarioContext(s)); + }); + + it('gives concurrent requests their own scenario ids', () => { + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + http + .get('/api/orders', { context: uwtScenarioContext(scenario('s-2')) }) + .subscribe(); + + const customers = backend.expectOne('/api/customers'); + const orders = backend.expectOne('/api/orders'); + expect(customers.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + expect(orders.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-2'); + customers.flush([]); + orders.flush([]); + }); + + it('sends the same id on every retry attempt', () => { + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .pipe(retry(1)) + .subscribe(); + + const first = backend.expectOne('/api/customers'); + expect(first.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + first.flush(null, { status: 503, statusText: 'Unavailable' }); + + const second = backend.expectOne('/api/customers'); + expect(second.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + second.flush([]); + }); + + it('passes an error through unchanged and leaves settling to the caller', () => { + const s = scenario('s-1'); + let received: unknown; + http + .get('/api/customers', { context: uwtScenarioContext(s) }) + .subscribe({ error: (error) => (received = error) }); + + backend + .expectOne('/api/customers') + .flush(null, { status: 500, statusText: 'Server Error' }); + + expect(received).toBeInstanceOf(HttpErrorResponse); + expect((received as HttpErrorResponse).status).toBe(500); + expect(s.fail).not.toHaveBeenCalled(); + expect(s.complete).not.toHaveBeenCalled(); + }); + + it('lets traceScenario fail the scenario on an error', () => { + const s = scenario('s-1'); + http + .get('/api/customers', { context: uwtScenarioContext(s) }) + .pipe(traceScenario(s)) + .subscribe({ error: () => undefined }); + + backend + .expectOne('/api/customers') + .flush(null, { status: 500, statusText: 'Server Error' }); + + expect(s.fail).toHaveBeenCalledTimes(1); + expect(s.fail).toHaveBeenCalledWith({ + error: expect.any(HttpErrorResponse) + }); + }); + + it('quietly sends a request whose URL cannot be parsed', () => { + const error = jest.spyOn(console, 'error'); + http + .get('http://[bad', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('http://[bad'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + expect(error).not.toHaveBeenCalled(); + req.flush([]); + }); + }); + + it('uses a custom header name', () => { + configure({ headerName: 'X-Journey-Id' }); + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.get('X-Journey-Id')).toBe('s-1'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + req.flush([]); + }); + + it('falls back to the default for an empty header name', () => { + configure({ headerName: '' }); + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + req.flush([]); + }); + + it('sends nothing while telemetry is off', () => { + configure(undefined, { provide: UWT_TELEMETRY_ENABLED, useValue: false }); + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + req.flush([]); + }); + + it('sends nothing during a server-side render', () => { + configure(undefined, { provide: PLATFORM_ID, useValue: 'server' }); + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.has('X-Uwt-Scenario-Id')).toBe(false); + req.flush([]); + }); + + it('compares against the realm’s own origin in an about:blank frame', () => { + // An about:blank or srcdoc frame: its URL's origin is "null", but the + // realm, and the base it resolves against, are its creator's. + configure(undefined, { + provide: DOCUMENT, + useValue: { + baseURI: document.baseURI, + location: { origin: 'null' }, + defaultView: { origin: document.location.origin } + } + }); + http + .get('/api/customers', { context: uwtScenarioContext(scenario('s-1')) }) + .subscribe(); + + const req = backend.expectOne('/api/customers'); + expect(req.request.headers.get('X-Uwt-Scenario-Id')).toBe('s-1'); + req.flush([]); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.ts new file mode 100644 index 0000000..ea3ce13 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-http/uwt-scenario-http.ts @@ -0,0 +1,133 @@ +import { DOCUMENT } from '@angular/common'; +import { + HttpContext, + HttpContextToken, + HttpInterceptorFn, + HttpRequest +} from '@angular/common/http'; +import { inject } from '@angular/core'; +import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import type { UwtScenario } from '../uwt-scenario/uwt-scenario'; +import { + uwtIsBrowser, + uwtSafe +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Default request header that carries a scenario's id to the backend. + * + * @group Utils + */ +export const UWT_DEFAULT_SCENARIO_HEADER = 'X-Uwt-Scenario-Id'; + +/** The id of the scenario a request belongs to, if any. */ +const UWT_SCENARIO_ID = new HttpContextToken( + () => undefined +); + +/** + * Options for {@link uwtScenarioHeaderInterceptor}. + * + * @group Interfaces + */ +export interface UwtScenarioHeaderOptions { + /** Request header name. Default {@link UWT_DEFAULT_SCENARIO_HEADER}. */ + headerName?: string; +} + +/** + * Marks an `HttpClient` request as part of a scenario, so + * {@link uwtScenarioHeaderInterceptor} sends the scenario's id with it. + * + * Requests are marked one by one: with several scenarios running at once, + * nothing else can tell which one a request belongs to. + * + * Always a new context, so it never leaks into one shared between requests; + * add other tokens to the result with `.set()`. + * + * @example + * ```typescript + * this.http + * .get('/api/customers', { context: uwtScenarioContext(scenario) }) + * .pipe(traceScenario(scenario)) + * .subscribe(); + * ``` + * + * @param scenario the scenario the request belongs to + * @returns a new context, to pass as the request's `context` option + * + * @group Utils + */ +export function uwtScenarioContext(scenario: UwtScenario): HttpContext { + return new HttpContext().set(UWT_SCENARIO_ID, scenario.id); +} + +/** + * An `HttpClient` interceptor that sends the id of the request's scenario + * (see {@link uwtScenarioContext}) as a request header, so the backend can + * log it and tie its work to the journey. + * + * Adds the header only to same-origin requests marked with a scenario, + * while telemetry is on, in a browser, and when the request doesn't already + * carry it. It never changes how a request succeeds or fails: settling the + * scenario stays with the caller. + * + * It judges "same-origin" by the URL as it sees it, so list it after any + * interceptor that rewrites URLs, such as one that prefixes an API host. + * + * @example + * ```typescript + * provideHttpClient(withInterceptors([uwtScenarioHeaderInterceptor()])); + * ``` + * + * @param options optional header name + * @returns the interceptor, for `withInterceptors` + * + * @group Utils + */ +export function uwtScenarioHeaderInterceptor( + options: UwtScenarioHeaderOptions = {} +): HttpInterceptorFn { + const headerName = options.headerName || UWT_DEFAULT_SCENARIO_HEADER; + return (req, next) => + next( + uwtSafe( + 'scenarioHeader.intercept', + () => withHeader(req, headerName), + req + ) + ); +} + +function withHeader( + req: HttpRequest, + headerName: string +): HttpRequest { + const scenarioId = req.context.get(UWT_SCENARIO_ID); + if ( + !scenarioId || + req.headers.has(headerName) || + !uwtTelemetryEnabled() || + !uwtIsBrowser() + ) { + return req; + } + // Same-origin only: a custom header on a cross-origin request needs a CORS + // preflight the other server may not allow. The realm's own origin, not + // its URL's: an about:blank or srcdoc frame inherits its creator's. + const document = inject(DOCUMENT); + const origin = document.defaultView?.origin ?? document.location.origin; + if (originOf(req.url, document.baseURI) !== origin) { + return req; + } + return req.clone({ setHeaders: { [headerName]: scenarioId } }); +} + +/** The URL's origin; `undefined` when it can't be parsed. */ +function originOf(url: string, base: string): string | undefined { + try { + return new URL(url, base).origin; + } catch { + return undefined; + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts index ff72d75..ef93000 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts @@ -1,9 +1,12 @@ +import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { + uwtIsBrowser, uwtSafe, uwtSafeVoid, uwtSafeVoidMaybeAsync } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtHostHandleOf } from './uwt-host-handle.util'; import { UwtBroadcastConnection, uwtConnectBroadcastChannel, @@ -84,6 +87,12 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { private isLeader = false; private releaseLeadership: () => void = () => undefined; private readonly enabled = uwtTelemetryEnabled(); + private readonly hostWindow = uwtIsBrowser() + ? inject(DOCUMENT).defaultView + : null; + + /** Whether this host put its channel name on the page's handle. */ + private published = false; constructor() { if (!this.enabled) { @@ -97,6 +106,10 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { } this.connection.onMessage((data) => this.onMessage(data)); + // Fragments nested in this page take this channel, so a shell with its + // own channel needs no setup in them. Leader or not: they talk to this + // page's host either way. + this.publishChannel(); this.releaseLeadership = uwtElectBroadcastHostLeader( this.connection.channelName, () => { @@ -116,10 +129,33 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { if (this.enabled) { this.forgetInThisRealm(); } + this.withdrawChannel(); this.releaseLeadership(); this.connection.close(); } + private publishChannel(): void { + uwtSafeVoid('broadcastHost.publishChannel', () => { + if (this.hostWindow) { + uwtHostHandleOf(this.hostWindow).channelName = + this.connection.channelName; + this.published = true; + } + }); + } + + private withdrawChannel(): void { + uwtSafeVoid('broadcastHost.withdrawChannel', () => { + if (!this.published || !this.hostWindow) { + return; + } + const handle = uwtHostHandleOf(this.hostWindow); + if (handle.channelName === this.connection.channelName) { + delete handle.channelName; + } + }); + } + private onMessage(data: unknown): void { uwtSafeVoid('broadcastHost.receive', () => { if (!this.isLeader || !uwtIsBroadcastMessage(data)) { diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index 1a03688..110c5f3 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -1,15 +1,23 @@ +import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { UwtTelemetryError, UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UWT_TELEMETRY_IDENTITY, + uwtTelemetryEnabled +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + uwtIsBrowser, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { UwtBroadcastConnection, uwtConnectBroadcastChannel, uwtIsBroadcastMessage } from './uwt-broadcast.messages'; +import { uwtJoinHttpRealm } from './uwt-host-handle.util'; import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; /** @@ -38,7 +46,16 @@ export class UwtBroadcastTelemetrySink implements OnDestroy { private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastSink'); + uwtConnectBroadcastChannel('broadcastSink', { followShellChannel: true }); + + /** This realm's `window`, whose HTTP requests the host's RUM client traces. */ + private readonly realmWindow = + uwtIsBrowser() && uwtTelemetryEnabled() + ? inject(DOCUMENT).defaultView + : null; + + /** Hands the realm back; set once the host's RUM client took it. */ + private leaveHttpRealm?: () => void; /** The host's session id, once it has answered. */ private sessionId?: string; @@ -63,6 +80,8 @@ export class UwtBroadcastTelemetrySink this.connection.onMessage((data) => this.onMessage(data)); this.connection.post({ kind: 'identity-request' }); + // Last, so nothing after it can throw and leave a join no one undoes. + this.joinHttpRealm(); } /** @inheritdoc */ @@ -119,6 +138,8 @@ export class UwtBroadcastTelemetrySink /** @inheritdoc */ ngOnDestroy(): void { this.connection.close(); + this.leaveHttpRealm?.(); + this.leaveHttpRealm = undefined; } private onMessage(data: unknown): void { @@ -128,6 +149,15 @@ export class UwtBroadcastTelemetrySink } this.sessionId = data.sessionId; this.userId = data.userId; + // The host is up, so its RUM sink exists: join now if this realm + // started too early to find it. + this.joinHttpRealm(); }); } + + private joinHttpRealm(): void { + if (this.realmWindow && !this.leaveHttpRealm) { + this.leaveHttpRealm = uwtJoinHttpRealm(this.realmWindow); + } + } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts index de09f92..cac81e7 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from '@angular/common'; import { inject, InjectionToken } from '@angular/core'; import { UwtTelemetryError, @@ -13,6 +14,7 @@ import { uwtIsBrowser, uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtFindOnHost } from './uwt-host-handle.util'; /** * Default `BroadcastChannel` name used between telemetry realms. @@ -24,8 +26,13 @@ export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; /** * Overrides the channel name shared by the host and its followers. * - * Both sides must agree. Change it only to run two independent telemetry - * networks on one origin. + * Both sides must agree. A fragment nested in the shell's page (an iframe, + * a Web Fragment) follows the shell's channel by itself, so set this in the + * shell (`provideUwtTelemetryBroadcastHost(channelName)`); a realm that + * forwards from outside the shell's page, such as a popup window, needs it + * too. Change it to run + * two independent telemetry networks on one origin, or to give each tab its + * own channel. * * @group Tokens */ @@ -204,20 +211,27 @@ export interface UwtBroadcastConnection { * channel name, feature-detects `BroadcastChannel`, and wraps every operation * in the library's fail-open guard. * + * The name is {@link UWT_BROADCAST_CHANNEL} when set. Otherwise, with + * `followShellChannel`, it is the channel of the shell this realm is nested + * in, so fragments follow a shell with its own channel without any setup. + * The fragment-side sink and log provider pass it; a host never does, since + * a shell embedded in another must keep its own channel. Last comes {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + * * Must be called from an injection context (a field initializer or a * constructor). */ export function uwtConnectBroadcastChannel( - operation: string + operation: string, + options?: { followShellChannel?: boolean } ): UwtBroadcastConnection { + // No lookup and no channel on the server or while telemetry is off: every + // call is a no-op. + const active = uwtIsBrowser() && uwtTelemetryEnabled(); const channelName = inject(UWT_BROADCAST_CHANNEL, { optional: true }) ?? + (active && options?.followShellChannel ? shellChannelName() : undefined) ?? UWT_DEFAULT_BROADCAST_CHANNEL; - // No channel on the server or while telemetry is off: every call is a no-op. - let channel = - uwtIsBrowser() && uwtTelemetryEnabled() - ? uwtOpenBroadcastChannel(channelName) - : undefined; + let channel = active ? uwtOpenBroadcastChannel(channelName) : undefined; return { channelName, @@ -241,6 +255,12 @@ export function uwtConnectBroadcastChannel( }; } +/** The channel of the shell this realm is nested in, if any. */ +function shellChannelName(): string | undefined { + const realm = inject(DOCUMENT).defaultView; + return realm ? uwtFindOnHost(realm, 'channelName') : undefined; +} + /** * Whether `value` is a valid `metadata` field: absent, or a non-array * object whose values are all finite numbers, strings, booleans, or null — diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index 5ee854e..146a3d3 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -5,7 +5,12 @@ import { } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; import { DOCUMENT } from '@angular/common'; -import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { + ApplicationInitStatus, + Injectable, + Injector, + PLATFORM_ID +} from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { @@ -36,6 +41,10 @@ import { uwtIsBroadcastMessage } from './uwt-broadcast.messages'; import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; /** @@ -1326,6 +1335,211 @@ describe('broadcast telemetry across realms', () => { ); }); + describe('tracing a fragment’s HTTP through the RUM realm', () => { + const KEY = Symbol.for('ngx-ui-watchtower.host'); + + /** The window running the RUM client, as its sink registers itself. */ + function rumWindow() { + const leave = jest.fn(); + const join = jest.fn(() => leave); + const window: { parent?: object; [KEY]: object } = { + [KEY]: { traceHttp: join } + }; + window.parent = window; + return { window, join, leave }; + } + + function fragmentIn(parent: object, enabled = true) { + const view = { parent }; + const realm = createRealm([ + UwtBroadcastTelemetrySink, + { provide: DOCUMENT, useValue: { defaultView: view } }, + { provide: UWT_TELEMETRY_ENABLED, useValue: enabled } + ]); + return { view, sink: realm.get(UwtBroadcastTelemetrySink) }; + } + + it('should hand its window to the RUM realm, and take it back on destroy', () => { + const rum = rumWindow(); + const fragment = fragmentIn(rum.window); + expect(rum.join).toHaveBeenCalledWith(fragment.view); + + fragment.sink.ngOnDestroy(); + expect(rum.leave).toHaveBeenCalledTimes(1); + }); + + it('should hand nothing over while telemetry is off', () => { + const rum = rumWindow(); + fragmentIn(rum.window, false); + + expect(rum.join).not.toHaveBeenCalled(); + }); + + it('should hand it over once the host announces itself, if it started too early', async () => { + const shellWindow: { parent?: object; [KEY]?: object } = {}; + shellWindow.parent = shellWindow; + fragmentIn(shellWindow); + + const join = jest.fn(() => jest.fn()); + shellWindow[KEY] = { traceHttp: join }; + const peer = new UwtBroadcastChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + peer.postMessage({ kind: 'identity', sessionId: 'session-1' }); + peer.postMessage({ kind: 'identity', sessionId: 'session-1' }); + await UwtBroadcastChannelStub.settle(); + + expect(join).toHaveBeenCalledTimes(1); + }); + + it('should hand it over even without BroadcastChannel', () => { + UwtBroadcastChannelStub.uninstall(); + const rum = rumWindow(); + fragmentIn(rum.window); + + expect(rum.join).toHaveBeenCalledTimes(1); + }); + + it('should hand it over at startup, before any telemetry is used', () => { + const rum = rumWindow(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { + provide: DOCUMENT, + useValue: { defaultView: { parent: rum.window } } + }, + provideUwtTelemetry({ + application: 'cart', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetrySink('broadcast') + ] + }); + TestBed.inject(ApplicationInitStatus); + + expect(rum.join).toHaveBeenCalledTimes(1); + }); + }); + + describe('following the shell’s channel', () => { + const KEY = Symbol.for('ngx-ui-watchtower.host'); + + /** A top-level window, with the handle a shell would leave on it. */ + function shellWindow(handle?: { channelName?: string }) { + const window: { parent?: object; [KEY]?: { channelName?: string } } = + handle ? { [KEY]: handle } : {}; + window.parent = window; + return window; + } + + /** Collects what arrives on `name`, as another realm would. */ + function listen(name: string): unknown[] { + const received: unknown[] = []; + new UwtBroadcastChannelStub(name).onmessage = (event) => + received.push(event.data); + return received; + } + + function fragmentIn(parent: object, channel?: string) { + const realm = createRealm([ + UwtBroadcastTelemetrySink, + UwtBroadcastLogApiProvider, + { provide: DOCUMENT, useValue: { defaultView: { parent } } }, + ...(channel + ? [{ provide: UWT_BROADCAST_CHANNEL, useValue: channel }] + : []) + ]); + realm.get(UwtBroadcastTelemetrySink); + return realm; + } + + function hostIn(window: object, channel?: string) { + return createRealm([ + RecordingSink, + { provide: UwtTelemetrySink, useExisting: RecordingSink }, + UwtTelemetryBroadcastHost, + { provide: DOCUMENT, useValue: { defaultView: window } }, + ...(channel + ? [{ provide: UWT_BROADCAST_CHANNEL, useValue: channel }] + : []) + ]).get(UwtTelemetryBroadcastHost); + } + + it('should use the channel of the shell it is nested in, for events and logs', async () => { + const onShellChannel = listen('shell-tab-1'); + const fragment = fragmentIn(shellWindow({ channelName: 'shell-tab-1' })); + fragment.get(UwtBroadcastLogApiProvider).send(storedRecord()); + await UwtBroadcastChannelStub.settle(); + + expect(onShellChannel).toEqual([ + { kind: 'identity-request' }, + expect.objectContaining({ kind: 'log' }) + ]); + }); + + it('should not look for the shell while telemetry is off', () => { + const shell = shellWindow({ channelName: 'shell-tab-1' }); + const readParent = jest.fn(() => shell); + const view = { + get parent() { + return readParent(); + } + }; + createRealm([ + UwtBroadcastTelemetrySink, + UwtBroadcastLogApiProvider, + { provide: DOCUMENT, useValue: { defaultView: view } }, + { provide: UWT_TELEMETRY_ENABLED, useValue: false } + ]).get(UwtBroadcastTelemetrySink); + + expect(readParent).not.toHaveBeenCalled(); + }); + + it('should keep a channel the fragment sets itself', async () => { + const onShellChannel = listen('shell-tab-1'); + const onOwnChannel = listen('own-channel'); + fragmentIn(shellWindow({ channelName: 'shell-tab-1' }), 'own-channel'); + await UwtBroadcastChannelStub.settle(); + + expect(onShellChannel).toEqual([]); + expect(onOwnChannel).toEqual([{ kind: 'identity-request' }]); + }); + + it('should publish the host’s channel on its window, and withdraw it on destroy', () => { + const window = shellWindow(); + const tabHost = hostIn(window, 'shell-tab-1'); + expect(window[KEY]?.channelName).toBe('shell-tab-1'); + + tabHost.ngOnDestroy(); + expect(window[KEY]?.channelName).toBeUndefined(); + }); + + it('should never take a channel from a shell above a host', () => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const window = { parent: shellWindow({ channelName: 'outer-channel' }) }; + const innerHost = hostIn(window); + + expect((window as { [KEY]?: { channelName?: string } })[KEY]).toEqual({ + channelName: UWT_DEFAULT_BROADCAST_CHANNEL + }); + innerHost.ngOnDestroy(); + }); + + it('should publish nothing while telemetry is off', () => { + const window = shellWindow(); + const offHost = createRealm([ + RecordingSink, + { provide: UwtTelemetrySink, useExisting: RecordingSink }, + UwtTelemetryBroadcastHost, + { provide: DOCUMENT, useValue: { defaultView: window } }, + { provide: UWT_TELEMETRY_ENABLED, useValue: false } + ]).get(UwtTelemetryBroadcastHost); + + expect(window[KEY]).toBeUndefined(); + offHost.ngOnDestroy(); + }); + }); + describe('uwtConnectBroadcastChannel', () => { it('should default the channel name to UWT_DEFAULT_BROADCAST_CHANNEL', () => { const connection = TestBed.runInInjectionContext(() => diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.spec.ts new file mode 100644 index 0000000..e8a74a5 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.spec.ts @@ -0,0 +1,142 @@ +import { + UwtHostHandle, + uwtFindOnHost, + uwtHostHandleOf, + uwtJoinHttpRealm +} from './uwt-host-handle.util'; + +const KEY = Symbol.for('ngx-ui-watchtower.host'); + +type FakeWindow = { parent?: FakeWindow; [KEY]?: UwtHostHandle }; + +function topLevel(handle?: UwtHostHandle): FakeWindow { + const window: FakeWindow = handle ? { [KEY]: handle } : {}; + window.parent = window; + return window; +} + +function rumHost(leave = jest.fn()) { + const traceHttp = jest.fn(() => leave); + return { window: topLevel({ traceHttp }), traceHttp, leave }; +} + +const asWindow = (window: FakeWindow) => window as unknown as Window; +const join = (realm: FakeWindow) => uwtJoinHttpRealm(asWindow(realm)); + +describe('uwtHostHandleOf', () => { + it('creates the handle once and hands back the same one', () => { + const window = topLevel(); + + const handle = uwtHostHandleOf(asWindow(window)); + handle.channelName = 'shell-channel'; + + expect(uwtHostHandleOf(asWindow(window))).toBe(handle); + expect(window[KEY]).toBe(handle); + }); +}); + +describe('uwtFindOnHost', () => { + it('finds each field on the nearest ancestor that has it', () => { + const outer = topLevel({ channelName: 'outer-channel' }); + const inner: FakeWindow = { + parent: outer, + [KEY]: { traceHttp: () => () => undefined } + }; + const fragment: FakeWindow = { parent: inner }; + + expect(uwtFindOnHost(asWindow(fragment), 'channelName')).toBe( + 'outer-channel' + ); + expect(uwtFindOnHost(asWindow(fragment), 'traceHttp')).toBe( + inner[KEY]?.traceHttp + ); + }); + + it('finds nothing in a top-level window', () => { + const window = topLevel({ channelName: 'own-channel' }); + + expect(uwtFindOnHost(asWindow(window), 'channelName')).toBeUndefined(); + }); +}); + +describe('uwtJoinHttpRealm', () => { + afterEach(() => jest.restoreAllMocks()); + + it('joins the parent window hosting RUM, and leaves it', () => { + const host = rumHost(); + const fragment: FakeWindow = { parent: host.window }; + + const leave = join(fragment); + expect(host.traceHttp).toHaveBeenCalledWith(fragment); + + leave?.(); + expect(host.leave).toHaveBeenCalledTimes(1); + }); + + it('looks past ancestors that do not host RUM', () => { + const host = rumHost(); + const outer: FakeWindow = { parent: host.window }; + const nested: FakeWindow = { parent: outer }; + + join(nested); + + expect(host.traceHttp).toHaveBeenCalledWith(nested); + }); + + it('does nothing in a top-level window', () => { + const host = rumHost(); + + expect(join(host.window)).toBeUndefined(); + expect(host.traceHttp).not.toHaveBeenCalled(); + }); + + it('does nothing when no ancestor hosts RUM', () => { + const fragment: FakeWindow = { parent: topLevel() }; + + expect(join(fragment)).toBeUndefined(); + }); + + it('stops where an ancestor cannot be reached', () => { + const fragment = { + get parent(): FakeWindow { + throw new DOMException('Blocked', 'SecurityError'); + } + }; + + expect(join(fragment)).toBeUndefined(); + }); + + it('looks past a cross-origin ancestor to the same-origin host above it', () => { + const host = rumHost(); + // A cross-origin window: `parent` is readable, anything else throws. + const crossOrigin = new Proxy({} as FakeWindow, { + get: (_target, key) => { + if (key === 'parent') { + return host.window; + } + throw new DOMException('Blocked', 'SecurityError'); + } + }); + const fragment: FakeWindow = { parent: crossOrigin }; + + join(fragment); + + expect(host.traceHttp).toHaveBeenCalledWith(fragment); + }); + + it('never lets a failing host break the app', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const host = rumHost( + jest.fn(() => { + throw new Error('leave failed'); + }) + ); + const leave = join({ parent: host.window }); + expect(() => leave?.()).not.toThrow(); + + host.traceHttp.mockImplementation(() => { + throw new Error('join failed'); + }); + expect(() => join({ parent: host.window })?.()).not.toThrow(); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.ts new file mode 100644 index 0000000..3636187 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-host-handle.util.ts @@ -0,0 +1,102 @@ +import { + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Where a shell leaves its handle for the frames nested in it, on its own + * `window`. Must match the key in the rum entry's `uwt-rum-realms.ts`. + */ +const UWT_HOST_KEY = Symbol.for('ngx-ui-watchtower.host'); + +/** + * What a shell offers the frames nested in it. Each field is set by the part + * of the shell that owns it, and looked up by nested fragments on its own. + */ +export interface UwtHostHandle { + /** The channel the shell's broadcast host listens on. */ + channelName?: string; + + /** + * Traces `realm`'s `fetch`/XHR with the shell's RUM client; returns the + * call that stops it. Set by the RUM sink. + */ + traceHttp?(realm: Window): () => void; +} + +/** This window's handle, created on first use. Shell side. */ +export function uwtHostHandleOf(window: Window): UwtHostHandle { + const slots = window as unknown as Record; + return (slots[UWT_HOST_KEY] ??= {}); +} + +/** + * `field` from the nearest ancestor window whose handle has it. Fragment + * side. Each field is looked up on its own, so a nested shell missing one + * doesn't hide an outer shell's. + * + * @param realm this realm's `window` + * @param field the handle field to look for + * @returns the value, or `undefined` when no ancestor offers it + */ +export function uwtFindOnHost( + realm: Window, + field: K +): UwtHostHandle[K] | undefined { + let current = realm; + let parent = parentOf(current); + while (parent && parent !== current) { + current = parent; + const value = handleOn(current)?.[field]; + if (value !== undefined) { + return value; + } + parent = parentOf(current); + } + return undefined; +} + +/** + * Hands `realm` to the nearest ancestor window running the RUM client, which + * then traces this realm's `fetch`/XHR requests like its own: same X-Ray + * header, same HTTP events, same session. + * + * @param realm this realm's `window` + * @returns the call that hands it back, or `undefined` when no ancestor + * hosts RUM (yet), so the caller can try again later + */ +export function uwtJoinHttpRealm(realm: Window): (() => void) | undefined { + const traceHttp = uwtFindOnHost(realm, 'traceHttp'); + if (!traceHttp) { + return undefined; + } + const leave = uwtSafe( + 'httpRealm.join', + () => traceHttp(realm), + () => undefined + ); + return () => uwtSafeVoid('httpRealm.leave', leave); +} + +/** A window's parent; `undefined` when it can't be reached. */ +function parentOf(window: Window): Window | undefined { + try { + return window.parent ?? undefined; + } catch { + return undefined; + } +} + +/** + * The handle on `window`, if any. A cross-origin window can't be read, but + * its parent still can, and may be the same-origin shell. + */ +function handleOn(window: Window): UwtHostHandle | undefined { + try { + return (window as unknown as Record)[ + UWT_HOST_KEY + ]; + } catch { + return undefined; + } +} diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 795967e..82d2eb3 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -107,6 +107,12 @@ export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenari export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; +export { + UWT_DEFAULT_SCENARIO_HEADER, + uwtScenarioContext, + uwtScenarioHeaderInterceptor +} from './lib/scenario/uwt-scenario-http/uwt-scenario-http'; +export type { UwtScenarioHeaderOptions } from './lib/scenario/uwt-scenario-http/uwt-scenario-http'; export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util';