From 24200c4a73bb8368be5d8acbbb9a7c4cd9276fd0 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 12:58:57 +0200 Subject: [PATCH 1/6] feat(module-analytics): add MockAnalyticsAdapter for recording tracked analytics events - Add MockAnalyticsAdapter implementing IAnalyticsAdapter, recording events in-memory for test assertions (getAnalytics, waitForAnalytic) - Add ./mock subpath export - Add vitest.config.ts to make the package a discoverable vitest project - Document the new adapter in README.md and add a changeset Implements equinor/fusion-core-tasks#1657 --- .changeset/module-analytics_mock-adapter.md | 28 +++ packages/modules/analytics/README.md | 33 ++++ packages/modules/analytics/package.json | 4 + .../__tests__/MockAnalyticsAdapter.test.ts | 115 ++++++++++++ .../src/mock/MockAnalyticsAdapter.ts | 173 ++++++++++++++++++ packages/modules/analytics/src/mock/index.ts | 29 +++ packages/modules/analytics/vitest.config.ts | 11 ++ 7 files changed, 393 insertions(+) create mode 100644 .changeset/module-analytics_mock-adapter.md create mode 100644 packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts create mode 100644 packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts create mode 100644 packages/modules/analytics/src/mock/index.ts create mode 100644 packages/modules/analytics/vitest.config.ts diff --git a/.changeset/module-analytics_mock-adapter.md b/.changeset/module-analytics_mock-adapter.md new file mode 100644 index 0000000000..6489daf62b --- /dev/null +++ b/.changeset/module-analytics_mock-adapter.md @@ -0,0 +1,28 @@ +--- +"@equinor/fusion-framework-module-analytics": minor +--- + +Add `MockAnalyticsAdapter` and `./mock` subpath for asserting on tracked analytics events in tests. + +```ts +import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; +import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + +const recorder = new MockAnalyticsAdapter(); + +enableAnalytics(configurator, (builder) => { + builder.setAdapter('mock', async () => recorder); +}); + +// ... exercise the app under test, then assert: +const event = await recorder.waitForAnalytic('button-click'); +expect(event.attributes?.section).toBe('header'); +``` + +### `getAnalytics(matcher?)` + +Returns recorded events synchronously, filtered by an event name, an array of names, or a predicate. Omit the matcher to get every recorded event. + +### `waitForAnalytic(matcher, options?)` + +Resolves with the first matching event, resolving immediately if one was already recorded, or waiting for a future one. Supports an optional `timeout` (ms) and `AbortSignal` so a test cannot hang indefinitely, and rejects if the adapter is disposed before a match occurs. diff --git a/packages/modules/analytics/README.md b/packages/modules/analytics/README.md index aadac039bf..df0921a09a 100644 --- a/packages/modules/analytics/README.md +++ b/packages/modules/analytics/README.md @@ -22,6 +22,7 @@ When a collector emits an event it is delivered to **every** registered adapter. | `@equinor/fusion-framework-module-analytics/adapters` | `ConsoleAnalyticsAdapter`, `FusionAnalyticsAdapter`, `IAnalyticsAdapter` | | `@equinor/fusion-framework-module-analytics/collectors` | `ContextSelectedCollector`, `AppSelectedCollector`, `AppLoadedCollector`, `IAnalyticsCollector` | | `@equinor/fusion-framework-module-analytics/logExporters` | `OTLPLogExporter`, `FusionOTLPLogExporter` | +| `@equinor/fusion-framework-module-analytics/mock` | `MockAnalyticsAdapter` — record tracked events for test assertions | ## Quick Start @@ -266,3 +267,35 @@ const configure = (configurator: IModulesConfigurator) => { }); } ``` + +## Testing + +Use `MockAnalyticsAdapter` from `@equinor/fusion-framework-module-analytics/mock` +to assert on tracked analytics events without exporting them to a real backend. +Register it like any other adapter via `setAdapter`, then query or await +recorded events from your test: + +```typescript +import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; +import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + +const recorder = new MockAnalyticsAdapter(); + +enableAnalytics(configurator, (builder) => { + builder.setAdapter('mock', async () => recorder); +}); + +// ... exercise the app under test, then assert: +const event = await recorder.waitForAnalytic('button-click'); +expect(event.attributes?.section).toBe('header'); + +// or synchronously inspect everything recorded so far: +expect(recorder.getAnalytics('page-view')).toHaveLength(1); +``` + +`getAnalytics(matcher?)` returns recorded events synchronously, filtered by an +event name, an array of names, or a predicate — omit the matcher to get every +recorded event. `waitForAnalytic(matcher, options?)` resolves with the first +matching event, resolving immediately if one was already recorded, or waiting +for a future one; it supports an optional `timeout` (ms) and `signal` +(`AbortSignal`), and rejects if the adapter is disposed before a match occurs. diff --git a/packages/modules/analytics/package.json b/packages/modules/analytics/package.json index f8b7163b88..a9a864d632 100644 --- a/packages/modules/analytics/package.json +++ b/packages/modules/analytics/package.json @@ -20,6 +20,10 @@ "./logExporters": { "import": "./dist/esm/logExporters/index.js", "types": "./dist/types/logExporters/index.d.ts" + }, + "./mock": { + "import": "./dist/esm/mock/index.js", + "types": "./dist/types/mock/index.d.ts" } }, "types": "dist/types/index.d.ts", diff --git a/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts b/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts new file mode 100644 index 0000000000..59c7f3da95 --- /dev/null +++ b/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { MockAnalyticsAdapter } from '../mock/MockAnalyticsAdapter.js'; +import type { AnalyticsEvent } from '../types.js'; + +const createEvent = (name: string, overrides: Partial = {}): AnalyticsEvent => ({ + name, + value: null, + ...overrides, +}); + +describe('MockAnalyticsAdapter', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('records events via registerAnalytic and returns them from getAnalytics', () => { + const adapter = new MockAnalyticsAdapter(); + + adapter.registerAnalytic(createEvent('button-click')); + adapter.registerAnalytic(createEvent('page-view')); + + expect(adapter.getAnalytics().map((e) => e.name)).toEqual(['button-click', 'page-view']); + }); + + it('filters getAnalytics by a single name, an array, and a predicate', () => { + const adapter = new MockAnalyticsAdapter(); + adapter.registerAnalytic(createEvent('button-click', { attributes: { section: 'header' } })); + adapter.registerAnalytic(createEvent('page-view')); + + expect(adapter.getAnalytics('button-click')).toHaveLength(1); + expect(adapter.getAnalytics(['button-click', 'page-view'])).toHaveLength(2); + expect(adapter.getAnalytics((e) => e.attributes?.section === 'header')).toHaveLength(1); + }); + + it('resolves waitForAnalytic immediately when a matching event is already recorded', async () => { + const adapter = new MockAnalyticsAdapter(); + adapter.registerAnalytic(createEvent('button-click')); + + const event = await adapter.waitForAnalytic('button-click'); + + expect(event.name).toBe('button-click'); + }); + + it('resolves waitForAnalytic when a matching event is recorded later', async () => { + const adapter = new MockAnalyticsAdapter(); + + const promise = adapter.waitForAnalytic('button-click'); + adapter.registerAnalytic(createEvent('page-view')); + adapter.registerAnalytic(createEvent('button-click')); + const event = await promise; + + expect(event.name).toBe('button-click'); + }); + + it('resolves waitForAnalytic via predicate matcher', async () => { + const adapter = new MockAnalyticsAdapter(); + + const promise = adapter.waitForAnalytic((e) => e.attributes?.id === 42); + adapter.registerAnalytic(createEvent('button-click', { attributes: { id: 1 } })); + adapter.registerAnalytic(createEvent('button-click', { attributes: { id: 42 } })); + const event = await promise; + + expect(event.attributes?.id).toBe(42); + }); + + it('rejects when the timeout elapses before a matching event is recorded', async () => { + vi.useFakeTimers(); + const adapter = new MockAnalyticsAdapter(); + + const promise = adapter.waitForAnalytic('button-click', { timeout: 500 }); + vi.advanceTimersByTime(501); + + await expect(promise).rejects.toThrow('waitForAnalytic timed out after 500ms'); + }); + + it('rejects when the AbortSignal fires before a matching event', async () => { + const adapter = new MockAnalyticsAdapter(); + const controller = new AbortController(); + + const promise = adapter.waitForAnalytic('button-click', { signal: controller.signal }); + controller.abort(); + + await expect(promise).rejects.toThrow(); + }); + + it('rejects immediately when passed an already-aborted signal', async () => { + const adapter = new MockAnalyticsAdapter(); + const controller = new AbortController(); + controller.abort(); + + await expect( + adapter.waitForAnalytic('button-click', { signal: controller.signal }), + ).rejects.toThrow(); + }); + + it('rejects pending waitForAnalytic calls when the adapter is disposed', async () => { + const adapter = new MockAnalyticsAdapter(); + + const promise = adapter.waitForAnalytic('button-click'); + adapter[Symbol.dispose](); + + await expect(promise).rejects.toThrow('disposed before a matching event was recorded'); + }); + + it('does not interfere with events recorded by another adapter instance', () => { + const adapterA = new MockAnalyticsAdapter(); + const adapterB = new MockAnalyticsAdapter(); + + adapterA.registerAnalytic(createEvent('button-click')); + + expect(adapterA.getAnalytics()).toHaveLength(1); + expect(adapterB.getAnalytics()).toHaveLength(0); + }); +}); diff --git a/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts b/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts new file mode 100644 index 0000000000..a8292eeb42 --- /dev/null +++ b/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts @@ -0,0 +1,173 @@ +import { Subject, filter, type Subscription } from 'rxjs'; + +import type { IAnalyticsAdapter } from '../adapters/AnalyticsAdapter.interface.js'; +import type { AnalyticsEvent } from '../types.js'; + +/** + * Selects which recorded events {@link MockAnalyticsAdapter.waitForAnalytic} or + * {@link MockAnalyticsAdapter.getAnalytics} act on. + * + * - `string` — matches `event.name` exactly. + * - `string[]` — matches if `event.name` is any of the given entries. + * - `(event) => boolean` — arbitrary predicate over the full event. + */ +export type AnalyticsEventMatcher = + | string + | string[] + | ((event: T) => boolean); + +/** Options accepted by {@link MockAnalyticsAdapter.waitForAnalytic}. */ +export interface WaitForAnalyticOptions { + /** + * Maximum time in milliseconds to wait for a matching event. + * When elapsed the returned promise rejects. + */ + timeout?: number; + /** + * AbortSignal that can cancel the wait early. + * When aborted the returned promise rejects with the signal's reason. + */ + signal?: AbortSignal; +} + +/** + * An {@link IAnalyticsAdapter} that records every tracked event in-memory instead + * of exporting it to a backend, for asserting on analytics in tests. + * + * @remarks + * Register it like any other adapter via {@link IAnalyticsConfigurator.setAdapter}; + * it does not interfere with other adapters registered alongside it. + * + * @template T - Analytics event type, defaults to {@link AnalyticsEvent}. + * + * @example + * ```ts + * import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + * + * const recorder = new MockAnalyticsAdapter(); + * enableAnalytics(configurator, (builder) => { + * builder.setAdapter('mock', async () => recorder); + * }); + * + * // ...later, in a test + * const event = await recorder.waitForAnalytic('button-click'); + * expect(event.attributes?.section).toBe('header'); + * ``` + */ +export class MockAnalyticsAdapter + implements IAnalyticsAdapter +{ + #events: T[] = []; + #events$ = new Subject(); + + /** + * Records the event so it is visible to {@link getAnalytics} and any pending + * {@link waitForAnalytic} calls. + * + * @param event - The analytics event to record. + */ + registerAnalytic(event: T): void { + this.#events.push(event); + this.#events$.next(event); + } + + /** + * Returns recorded events matching `matcher`, in dispatch order. + * + * @param matcher - Event name, array of names, or a predicate. Omit to get every recorded event. + * @returns Matching recorded events. + */ + getAnalytics(matcher?: AnalyticsEventMatcher): T[] { + // No matcher: return every event recorded so far. + if (matcher === undefined) return [...this.#events]; + // Narrow down to events accepted by the matcher. + return this.#events.filter((event) => this.#matches(event, matcher)); + } + + /** + * Waits for the next event matching `matcher`, resolving immediately if a + * matching event was already recorded. + * + * @param matcher - Event name, array of names, or a predicate. + * @param options - Optional timeout (ms) or AbortSignal. + * @returns A promise that resolves with the first matching event. + */ + waitForAnalytic(matcher: AnalyticsEventMatcher, options?: WaitForAnalyticOptions): Promise { + // Already recorded: resolve immediately rather than only watching future events. + const recorded = this.#events.find((event) => this.#matches(event, matcher)); + // Already recorded: resolve immediately rather than only watching future events. + if (recorded) return Promise.resolve(recorded); + + return new Promise((resolve, reject) => { + const { timeout: ms, signal } = options ?? {}; + + // Fail fast without subscribing when the caller already aborted. + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + + let timer: ReturnType | undefined; + // Declared before subscribing so `complete` can reach it even when the + // adapter is already disposed and fires synchronously during `subscribe`. + let sub: Subscription | undefined; + + const cleanup = () => { + clearTimeout(timer); + sub?.unsubscribe(); + }; + + // Only forward events accepted by the matcher to the subscriber below. + sub = this.#events$.pipe(filter((event) => this.#matches(event, matcher))).subscribe({ + next: (event) => { + cleanup(); + resolve(event); + }, + complete: () => { + cleanup(); + reject(new Error('MockAnalyticsAdapter disposed before a matching event was recorded')); + }, + }); + + // Only arm a timeout when the caller opted in. + if (ms !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`waitForAnalytic timed out after ${ms}ms`)); + }, ms); + } + + // Only wire abort handling when the caller passed a signal. + if (signal) { + signal.addEventListener( + 'abort', + () => { + cleanup(); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + } + }); + } + + /** + * Tests whether `event` satisfies `matcher`. + * + * @param event - Event to test. + * @param matcher - Event name, array of names, or a predicate. + * @returns Whether `event` matches. + */ + #matches(event: T, matcher: AnalyticsEventMatcher): boolean { + // String matcher: compare event name directly. + if (typeof matcher === 'string') return event.name === matcher; + // Array matcher: match against any of the given names. + if (Array.isArray(matcher)) return matcher.includes(event.name); + return matcher(event); + } + + /** Completes the internal event stream, rejecting any pending `waitForAnalytic` calls. */ + [Symbol.dispose]() { + this.#events$.complete(); + } +} diff --git a/packages/modules/analytics/src/mock/index.ts b/packages/modules/analytics/src/mock/index.ts new file mode 100644 index 0000000000..d7747617b6 --- /dev/null +++ b/packages/modules/analytics/src/mock/index.ts @@ -0,0 +1,29 @@ +/** + * Mock analytics adapter for tests: records tracked events in-memory instead + * of exporting them to a backend. + * + * @remarks + * Register it like any other {@link IAnalyticsAdapter} via + * {@link IAnalyticsConfigurator.setAdapter} — it observes tracked events + * alongside real adapters without affecting their delivery. + * + * @example + * ```ts + * import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + * + * const recorder = new MockAnalyticsAdapter(); + * enableAnalytics(configurator, (builder) => { + * builder.setAdapter('mock', async () => recorder); + * }); + * + * const event = await recorder.waitForAnalytic('button-click'); + * expect(event.attributes?.section).toBe('header'); + * ``` + * + * @packageDocumentation + */ +export { + MockAnalyticsAdapter, + type AnalyticsEventMatcher, + type WaitForAnalyticOptions, +} from './MockAnalyticsAdapter.js'; diff --git a/packages/modules/analytics/vitest.config.ts b/packages/modules/analytics/vitest.config.ts new file mode 100644 index 0000000000..f00c6cabd8 --- /dev/null +++ b/packages/modules/analytics/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject } from 'vitest/config'; + +import { name, version } from './package.json'; + +export default defineProject({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + name: `${name}@${version}`, + }, +}); From e635b79b549de5d9202ae4c575f6e15f30ee13fb Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 13:06:10 +0200 Subject: [PATCH 2/6] test(module-analytics): verify MockAnalyticsAdapter through the real module pipeline The existing unit tests only exercise MockAnalyticsAdapter's own logic in isolation. Add an integration test, mirroring the pattern used by msal and service-discovery's mock tests, that wires it through the real enableAnalytics -> ModulesConfigurator -> AnalyticsProvider.initialize() pipeline to prove events from provider.trackAnalytic and a real collector actually reach it, and that it doesn't interfere with other adapters. --- .../mock/analytics-mock-adapter.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/modules/analytics/src/__tests__/mock/analytics-mock-adapter.test.ts diff --git a/packages/modules/analytics/src/__tests__/mock/analytics-mock-adapter.test.ts b/packages/modules/analytics/src/__tests__/mock/analytics-mock-adapter.test.ts new file mode 100644 index 0000000000..e31466b331 --- /dev/null +++ b/packages/modules/analytics/src/__tests__/mock/analytics-mock-adapter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; +import { Subject } from 'rxjs'; + +import { enableAnalytics } from '../../enable-analytics.js'; +import { ConsoleAnalyticsAdapter } from '../../adapters/ConsoleAnalyticsAdapter.js'; +import { MockAnalyticsAdapter } from '../../mock/MockAnalyticsAdapter.js'; +import type { IAnalyticsConfigurator } from '../../AnalyticsConfigurator.interface.js'; +import type { IAnalyticsProvider } from '../../AnalyticsProvider.interface.js'; +import type { AnalyticsEvent } from '../../types.js'; + +/** + * Initializes the analytics module through the real module system, with a + * `MockAnalyticsAdapter` registered alongside whatever the test configures. + * + * @remarks + * Deliberately avoids hand-building an `AnalyticsProvider`. Testing the + * adapter's own logic in isolation (see `MockAnalyticsAdapter.test.ts`) can't + * prove it actually receives events through the real configure -> initialize + * -> collector/adapter dispatch pipeline every other adapter goes through. + * + * @param configure - Optional callback to register additional adapters/collectors. + * @returns The real `IAnalyticsProvider` instance and the recording adapter. + */ +const initializeWith = async ( + configure?: (builder: IAnalyticsConfigurator) => void, +): Promise<{ provider: IAnalyticsProvider; recorder: MockAnalyticsAdapter }> => { + const recorder = new MockAnalyticsAdapter(); + const configurator = new ModulesConfigurator([]); + + enableAnalytics(configurator, (builder) => { + builder.setAdapter('mock', async () => recorder); + configure?.(builder); + }); + + const instances = await configurator.initialize(); + const provider = (instances as unknown as { analytics: IAnalyticsProvider }).analytics; + + return { provider, recorder }; +}; + +describe('MockAnalyticsAdapter (through the real analytics module)', () => { + it('observes events pushed via provider.trackAnalytic', async () => { + const { provider, recorder } = await initializeWith(); + + provider.trackAnalytic({ name: 'button-click', value: 'save' }); + + expect(recorder.getAnalytics('button-click')).toHaveLength(1); + }); + + it('observes events emitted by a real registered collector', async () => { + const clicks$ = new Subject(); + const { recorder } = await initializeWith((builder) => { + builder.setCollector('clicks', async () => clicks$); + }); + + clicks$.next({ name: 'window-click', value: 42 }); + + const event = await recorder.waitForAnalytic('window-click'); + expect(event.value).toBe(42); + }); + + it('does not interfere with other adapters registered alongside it', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const { provider, recorder } = await initializeWith((builder) => { + builder.setAdapter('console', async () => new ConsoleAnalyticsAdapter()); + }); + + provider.trackAnalytic({ name: 'page-view', value: null }); + + expect(recorder.getAnalytics('page-view')).toHaveLength(1); + expect(logSpy).toHaveBeenCalledWith('Analytics::Adapter::Console', { + name: 'page-view', + value: null, + }); + + logSpy.mockRestore(); + }); +}); From 1824fc422b27edbde7b6a4f80806aeddb9d7286c Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 13:10:05 +0200 Subject: [PATCH 3/6] docs(module-analytics): move testing docs to docs/testing.md, mirror in vue-press Adopt the docs/ convention already used by the event, http, and module packages: extract the README's inline Testing section into packages/modules/analytics/docs/testing.md, link to it from README, and add a vue-press mirror page (@include) with a sidebar entry. --- .changeset/docs_analytics-testing-docs.md | 9 +++ packages/modules/analytics/README.md | 36 ++------- packages/modules/analytics/docs/testing.md | 80 +++++++++++++++++++ vue-press/src/.vuepress/sidebar.ts | 4 + .../src/modules/analytics/docs/testing.md | 10 +++ 5 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 .changeset/docs_analytics-testing-docs.md create mode 100644 packages/modules/analytics/docs/testing.md create mode 100644 vue-press/src/modules/analytics/docs/testing.md diff --git a/.changeset/docs_analytics-testing-docs.md b/.changeset/docs_analytics-testing-docs.md new file mode 100644 index 0000000000..531f1434a7 --- /dev/null +++ b/.changeset/docs_analytics-testing-docs.md @@ -0,0 +1,9 @@ +--- +"@equinor/fusion-framework-docs": patch +--- + +Add the analytics module's `docs/testing.md` (`MockAnalyticsAdapter`, recording and awaiting +tracked events, and using a bespoke `ModulesConfigurator` in tests) to the vue-press site, +matching the `event`/`http`/`module` `docs/` convention: `README.md` links to it and the new +`analytics/docs/testing.md` page `@include`s the package's own doc instead of duplicating +content. The sidebar is updated to match. diff --git a/packages/modules/analytics/README.md b/packages/modules/analytics/README.md index df0921a09a..017cf5067d 100644 --- a/packages/modules/analytics/README.md +++ b/packages/modules/analytics/README.md @@ -24,6 +24,12 @@ When a collector emits an event it is delivered to **every** registered adapter. | `@equinor/fusion-framework-module-analytics/logExporters` | `OTLPLogExporter`, `FusionOTLPLogExporter` | | `@equinor/fusion-framework-module-analytics/mock` | `MockAnalyticsAdapter` — record tracked events for test assertions | +## Documentation + +| Topic | Description | +|---|---| +| [Testing](docs/testing.md) | `MockAnalyticsAdapter`, recording and awaiting tracked events, and using a bespoke `ModulesConfigurator` in tests | + ## Quick Start Call `enableAnalytics` inside your application or portal configuration callback @@ -270,32 +276,4 @@ const configure = (configurator: IModulesConfigurator) => { ## Testing -Use `MockAnalyticsAdapter` from `@equinor/fusion-framework-module-analytics/mock` -to assert on tracked analytics events without exporting them to a real backend. -Register it like any other adapter via `setAdapter`, then query or await -recorded events from your test: - -```typescript -import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; -import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; - -const recorder = new MockAnalyticsAdapter(); - -enableAnalytics(configurator, (builder) => { - builder.setAdapter('mock', async () => recorder); -}); - -// ... exercise the app under test, then assert: -const event = await recorder.waitForAnalytic('button-click'); -expect(event.attributes?.section).toBe('header'); - -// or synchronously inspect everything recorded so far: -expect(recorder.getAnalytics('page-view')).toHaveLength(1); -``` - -`getAnalytics(matcher?)` returns recorded events synchronously, filtered by an -event name, an array of names, or a predicate — omit the matcher to get every -recorded event. `waitForAnalytic(matcher, options?)` resolves with the first -matching event, resolving immediately if one was already recorded, or waiting -for a future one; it supports an optional `timeout` (ms) and `signal` -(`AbortSignal`), and rejects if the adapter is disposed before a match occurs. +See [Testing](docs/testing.md) for using `MockAnalyticsAdapter` to record and assert on tracked analytics events without exporting them to a real backend. diff --git a/packages/modules/analytics/docs/testing.md b/packages/modules/analytics/docs/testing.md new file mode 100644 index 0000000000..946720c3ce --- /dev/null +++ b/packages/modules/analytics/docs/testing.md @@ -0,0 +1,80 @@ +# Testing + +Use `MockAnalyticsAdapter` from `@equinor/fusion-framework-module-analytics/mock` to assert on tracked analytics events without exporting them to a real backend. Register it like any other adapter via `setAdapter`, then query or await recorded events from your test: + +```ts +import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; +import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + +const recorder = new MockAnalyticsAdapter(); + +enableAnalytics(configurator, (builder) => { + builder.setAdapter('mock', async () => recorder); +}); + +// ... exercise the app under test, then assert: +const event = await recorder.waitForAnalytic('button-click'); +expect(event.attributes?.section).toBe('header'); + +// or synchronously inspect everything recorded so far: +expect(recorder.getAnalytics('page-view')).toHaveLength(1); +``` + +`MockAnalyticsAdapter` is a genuine `IAnalyticsAdapter` implementation — it works the same way through the real `enableAnalytics` configuration pipeline as `ConsoleAnalyticsAdapter` or `FusionAnalyticsAdapter`, so registering it alongside other adapters doesn't change their behavior. + +## `getAnalytics(matcher?)` + +Returns recorded events synchronously, filtered by an event name, an array of names, or a predicate. Omit the matcher to get every recorded event. + +```ts +recorder.getAnalytics(); // every recorded event +recorder.getAnalytics('button-click'); // by name +recorder.getAnalytics(['button-click', 'page-view']); // any of these names +recorder.getAnalytics((event) => event.attributes?.section === 'header'); // predicate +``` + +## `waitForAnalytic(matcher, options?)` + +Resolves with the first matching event — immediately if one was already recorded, or waiting for a future one. Supports an optional `timeout` (ms) and `signal` (`AbortSignal`) so a test can't hang indefinitely, and rejects if the adapter is disposed before a match occurs. + +```ts +// Rejects after 1000ms if the event never fires +const event = await recorder.waitForAnalytic('button-click', { timeout: 1000 }); + +// Rejects immediately when the signal aborts +const controller = new AbortController(); +const event = await recorder.waitForAnalytic('button-click', { signal: controller.signal }); +``` + +## Using a bespoke `ModulesConfigurator` + +`MockAnalyticsAdapter` works the same way with a manually composed set of modules — no app or portal host required: + +```ts +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; +import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; +import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; + +const recorder = new MockAnalyticsAdapter(); +const configurator = new ModulesConfigurator([]); + +enableAnalytics(configurator, (builder) => { + builder.setAdapter('mock', async () => recorder); +}); + +const { analytics } = await configurator.initialize(); +analytics.trackAnalytic({ name: 'button-click', value: 'save' }); + +expect(recorder.getAnalytics('button-click')).toHaveLength(1); +``` + +## Disposal + +`MockAnalyticsAdapter` completes its internal event stream on `[Symbol.dispose]()`, rejecting any pending `waitForAnalytic` calls instead of leaving them hanging: + +```ts +const pending = recorder.waitForAnalytic('button-click'); +recorder[Symbol.dispose](); + +await expect(pending).rejects.toThrow('disposed before a matching event was recorded'); +``` diff --git a/vue-press/src/.vuepress/sidebar.ts b/vue-press/src/.vuepress/sidebar.ts index d4e05f4c4f..1c155062f1 100644 --- a/vue-press/src/.vuepress/sidebar.ts +++ b/vue-press/src/.vuepress/sidebar.ts @@ -453,6 +453,10 @@ export default sidebar({ text: 'React', link: 'react.md', }, + { + text: 'Testing', + link: 'docs/testing.md', + }, ], }, { diff --git a/vue-press/src/modules/analytics/docs/testing.md b/vue-press/src/modules/analytics/docs/testing.md new file mode 100644 index 0000000000..bdd12396be --- /dev/null +++ b/vue-press/src/modules/analytics/docs/testing.md @@ -0,0 +1,10 @@ +--- +title: Analytics Testing +description: MockAnalyticsAdapter, recording and awaiting tracked events, and using a bespoke ModulesConfigurator in tests. +category: Module +tag: + - analytics + - testing +--- + + From adbcd629420a2921afbf1aa3ea0b2858190c0046 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 13:13:25 +0200 Subject: [PATCH 4/6] docs(module-analytics): slim README to overview, split domain docs into docs/ Move Adapters, Collectors, and Tracking Events Manually out of the README into docs/adapters.md, docs/collectors.md, and docs/tracking-events.md, matching the event/http/module docs/ convention. Drops duplicate Configuration/Creating Custom Collectors content that had accumulated in the old README's Tracking Events Manually section. README is now a slim overview + entry points + documentation table + quick start, linking out to docs/ for the domain reference. Mirrors and sidebar entries added in vue-press. --- .changeset/docs_analytics-docs-restructure.md | 9 + .changeset/docs_analytics-testing-docs.md | 9 - packages/modules/analytics/README.md | 233 ++---------------- packages/modules/analytics/docs/adapters.md | 79 ++++++ packages/modules/analytics/docs/collectors.md | 91 +++++++ .../modules/analytics/docs/tracking-events.md | 16 ++ vue-press/src/.vuepress/sidebar.ts | 12 + .../src/modules/analytics/docs/adapters.md | 9 + .../src/modules/analytics/docs/collectors.md | 9 + .../modules/analytics/docs/tracking-events.md | 9 + 10 files changed, 249 insertions(+), 227 deletions(-) create mode 100644 .changeset/docs_analytics-docs-restructure.md delete mode 100644 .changeset/docs_analytics-testing-docs.md create mode 100644 packages/modules/analytics/docs/adapters.md create mode 100644 packages/modules/analytics/docs/collectors.md create mode 100644 packages/modules/analytics/docs/tracking-events.md create mode 100644 vue-press/src/modules/analytics/docs/adapters.md create mode 100644 vue-press/src/modules/analytics/docs/collectors.md create mode 100644 vue-press/src/modules/analytics/docs/tracking-events.md diff --git a/.changeset/docs_analytics-docs-restructure.md b/.changeset/docs_analytics-docs-restructure.md new file mode 100644 index 0000000000..e3116f92c6 --- /dev/null +++ b/.changeset/docs_analytics-docs-restructure.md @@ -0,0 +1,9 @@ +--- +"@equinor/fusion-framework-docs": patch +--- + +Add the analytics module's `docs/` pages (`adapters.md`, `collectors.md`, `tracking-events.md`, +`testing.md`) to the vue-press site, matching the `event`/`http`/`module` `docs/` convention: +`README.md` is slimmed to an overview, entry points, and a documentation table, and the new +`analytics/docs/*.md` pages `@include` the package's own docs instead of duplicating content. +The sidebar is updated to match. diff --git a/.changeset/docs_analytics-testing-docs.md b/.changeset/docs_analytics-testing-docs.md deleted file mode 100644 index 531f1434a7..0000000000 --- a/.changeset/docs_analytics-testing-docs.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@equinor/fusion-framework-docs": patch ---- - -Add the analytics module's `docs/testing.md` (`MockAnalyticsAdapter`, recording and awaiting -tracked events, and using a bespoke `ModulesConfigurator` in tests) to the vue-press site, -matching the `event`/`http`/`module` `docs/` convention: `README.md` links to it and the new -`analytics/docs/testing.md` page `@include`s the package's own doc instead of duplicating -content. The sidebar is updated to match. diff --git a/packages/modules/analytics/README.md b/packages/modules/analytics/README.md index 017cf5067d..53a59e6afc 100644 --- a/packages/modules/analytics/README.md +++ b/packages/modules/analytics/README.md @@ -3,6 +3,15 @@ Fusion Framework module for collecting and exporting application analytics using OpenTelemetry standards. +## Who should use this + +- **Application and portal developers** who want to track user interactions + (clicks, context changes, app usage) without wiring up telemetry by hand. +- **Module authors** who want their module's lifecycle events picked up by + analytics automatically via a collector. +- **Test authors** who need to assert which analytics events an app or + collector produced. + ## Overview The analytics module provides a pluggable **adapter/collector** architecture: @@ -28,6 +37,9 @@ When a collector emits an event it is delivered to **every** registered adapter. | Topic | Description | |---|---| +| [Adapters](docs/adapters.md) | `ConsoleAnalyticsAdapter`, `FusionAnalyticsAdapter`, and creating a custom `IAnalyticsAdapter` | +| [Collectors](docs/collectors.md) | Built-in collectors (context/app selection, app loaded) and creating a custom `IAnalyticsCollector` | +| [Tracking Events Manually](docs/tracking-events.md) | `provider.trackAnalytic` / `trackAnalytic$` for ad-hoc event tracking | | [Testing](docs/testing.md) | `MockAnalyticsAdapter`, recording and awaiting tracked events, and using a bespoke `ModulesConfigurator` in tests | ## Quick Start @@ -59,221 +71,6 @@ const configure = (configurator) => { > Fusion Framework module system. Manual initialisation is only required when > accessing the provider directly. -## Adapters - -Adapters implement `IAnalyticsAdapter` and are responsible for processing and -sending analytics data to their destinations. All adapters support async -initialisation and will be initialised automatically when the provider starts. - -### ConsoleAnalyticsAdapter - -Logs every analytics event to the browser console. Useful for development and -debugging. No configuration required. - -```typescript -builder.setAdapter('console', async () => new ConsoleAnalyticsAdapter()); -``` - -### FusionAnalyticsAdapter - -Forwards analytics events to an OpenTelemetry-compatible log endpoint via a -bundled `LoggerProvider`. - -Configuration options: - -| Option | Type | Description | -|---|---|---| -| `portalId` | `string` | Portal identifier included in every log record | -| `logExporter` | `OTLPExporterBase` | OTLP log exporter for transport | - -#### Using `OTLPLogExporter` (direct HTTP) - -```typescript -import { OTLPLogExporter } from '@equinor/fusion-framework-module-analytics/logExporters'; -import { FusionAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; - -builder.setAdapter('fusion-log', async () => { - const logExporter = new OTLPLogExporter({ - url: 'https://example.com/v1/logs', - headers: { 'Content-Type': 'application/json' }, - }); - return new FusionAnalyticsAdapter({ portalId: 'my-portal', logExporter }); -}); -``` - -#### Using `FusionOTLPLogExporter` (service discovery HTTP client) - -```typescript -import { FusionOTLPLogExporter } from '@equinor/fusion-framework-module-analytics/logExporters'; -import { FusionAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; - -builder.setAdapter('fusion', async (args) => { - if (args.hasModule('serviceDiscovery')) { - const sd = await args.requireInstance('serviceDiscovery'); - const httpClient = await sd.createClient('analytics'); - const logExporter = new FusionOTLPLogExporter(httpClient); - return new FusionAnalyticsAdapter({ portalId: 'my-portal', logExporter }); - } - console.error('Service discovery unavailable — analytics adapter not created'); -}); -``` - -### Creating a Custom Adapter - -Implement `IAnalyticsAdapter` and register it with `setAdapter`: - -```typescript -import type { IAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; -import type { AnalyticsEvent } from '@equinor/fusion-framework-module-analytics'; - -class MyRemoteAdapter implements IAnalyticsAdapter { - registerAnalytic(event: AnalyticsEvent): void { - navigator.sendBeacon('/analytics', JSON.stringify(event)); - } - - [Symbol.dispose](): void { - // cleanup if needed - } -} - -builder.setAdapter('remote', async () => new MyRemoteAdapter()); -``` - -## Collectors - -Collectors implement `IAnalyticsCollector` (or extend `BaseCollector`) and emit -`AnalyticsEvent` objects that are forwarded to all adapters. All collectors -support async initialisation. - -### ContextSelectedCollector - -Emits an event when the active Fusion context changes. Includes the new context, -the previous context, and the current app key in attributes. - -```typescript -builder.setCollector('context-selected', async (args) => { - const ctx = await args.requireInstance('context'); - const app = await args.requireInstance('app'); - return new ContextSelectedCollector(ctx, app); -}); -``` - -### AppSelectedCollector - -Emits an event when the active application changes. Includes the new and -previous app key metadata. - -```typescript -builder.setCollector('app-selected', async (args) => { - const app = await args.requireInstance('app'); - return new AppSelectedCollector(app); -}); -``` - -### AppLoadedCollector - -Emits an event when an application's modules finish loading. Includes app -manifest metadata and the current context (if available). - -```typescript -builder.setCollector('app-loaded', async (args) => { - const event = await args.requireInstance('event'); - const app = await args.requireInstance('app'); - return new AppLoadedCollector(event, app); -}); -``` - -### Creating a Custom Collector - -Extend `BaseCollector` with a Zod schema for validation: - -```typescript -import { BaseCollector, createSchema } from '@equinor/fusion-framework-module-analytics/collectors'; -import { z } from 'zod'; -import { of } from 'rxjs'; - -const schema = createSchema(z.string(), z.object({ page: z.string() })); - -class PageViewCollector extends BaseCollector { - constructor() { - super('page-view', schema); - } - - _initialize() { - return of({ value: window.location.pathname, attributes: { page: document.title } }); - } -} -``` - -## Tracking Events Manually - -The provider exposes methods for ad-hoc event tracking outside of collectors: - -```typescript -// Single event -provider.trackAnalytic({ - name: 'button-click', - value: 'save', - attributes: { section: 'toolbar' }, -}); - -// Observable stream -const subscription = provider.trackAnalytic$(myEvent$); -// later: subscription.unsubscribe(); -``` - -#### Configuration - -The Context Selected Collector needs the context provider. - -##### Example configuration - -```typescript -import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; -import { ContextSelectedCollector } from '@equinor/fusion-framework-module-analytics/collectors'; - -const configure = (configurator: IModulesConfigurator) => { - enableAnalytics(configurator, (builder) => { - builder.setCollector('context-selected', async (args) => { - const contextProvider = await args.requireInstance('context'); - const appProvider = await args.requireInstance('app'); - return new ContextSelectedCollector(contextProvider, appProvider); - }); - }); -} -``` - -### Creating Custom Collectors - -You can create custom analytics collector by extending the `BaseCollector` class, -or implement the `IAnalyticsCollector` interface and add it in configuration. - -#### Example Custom Collector - -```typescript -import { type AnalyticsEvent, enableAnalytics } from '@equinor/fusion-framework-module-analytics'; - -const configure = (configurator: IModulesConfigurator) => { - enableAnalytics(configurator, (builder) => { - builder.setCollector('click-test', async () => { - const subject = new Subject(); - window.addEventListener('click', (e) => { - subject.next({ - name: 'window-clicker', - value: 42, - }); - }); - - return { - subscribe: (subscriber) => { - return subject.subscribe(subscriber); - }, - }; - }); - }); -} -``` - -## Testing - -See [Testing](docs/testing.md) for using `MockAnalyticsAdapter` to record and assert on tracked analytics events without exporting them to a real backend. +See [Adapters](docs/adapters.md), [Collectors](docs/collectors.md), and +[Tracking Events Manually](docs/tracking-events.md) for the full adapter/collector +reference and how to build your own. diff --git a/packages/modules/analytics/docs/adapters.md b/packages/modules/analytics/docs/adapters.md new file mode 100644 index 0000000000..2399a988b3 --- /dev/null +++ b/packages/modules/analytics/docs/adapters.md @@ -0,0 +1,79 @@ +# Adapters + +Adapters implement `IAnalyticsAdapter` and are responsible for processing and +sending analytics data to their destinations. All adapters support async +initialisation and will be initialised automatically when the provider starts. + +## ConsoleAnalyticsAdapter + +Logs every analytics event to the browser console. Useful for development and +debugging. No configuration required. + +```typescript +builder.setAdapter('console', async () => new ConsoleAnalyticsAdapter()); +``` + +## FusionAnalyticsAdapter + +Forwards analytics events to an OpenTelemetry-compatible log endpoint via a +bundled `LoggerProvider`. + +Configuration options: + +| Option | Type | Description | +|---|---|---| +| `portalId` | `string` | Portal identifier included in every log record | +| `logExporter` | `OTLPExporterBase` | OTLP log exporter for transport | + +### Using `OTLPLogExporter` (direct HTTP) + +```typescript +import { OTLPLogExporter } from '@equinor/fusion-framework-module-analytics/logExporters'; +import { FusionAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; + +builder.setAdapter('fusion-log', async () => { + const logExporter = new OTLPLogExporter({ + url: 'https://example.com/v1/logs', + headers: { 'Content-Type': 'application/json' }, + }); + return new FusionAnalyticsAdapter({ portalId: 'my-portal', logExporter }); +}); +``` + +### Using `FusionOTLPLogExporter` (service discovery HTTP client) + +```typescript +import { FusionOTLPLogExporter } from '@equinor/fusion-framework-module-analytics/logExporters'; +import { FusionAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; + +builder.setAdapter('fusion', async (args) => { + if (args.hasModule('serviceDiscovery')) { + const sd = await args.requireInstance('serviceDiscovery'); + const httpClient = await sd.createClient('analytics'); + const logExporter = new FusionOTLPLogExporter(httpClient); + return new FusionAnalyticsAdapter({ portalId: 'my-portal', logExporter }); + } + console.error('Service discovery unavailable — analytics adapter not created'); +}); +``` + +## Creating a Custom Adapter + +Implement `IAnalyticsAdapter` and register it with `setAdapter`: + +```typescript +import type { IAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/adapters'; +import type { AnalyticsEvent } from '@equinor/fusion-framework-module-analytics'; + +class MyRemoteAdapter implements IAnalyticsAdapter { + registerAnalytic(event: AnalyticsEvent): void { + navigator.sendBeacon('/analytics', JSON.stringify(event)); + } + + [Symbol.dispose](): void { + // cleanup if needed + } +} + +builder.setAdapter('remote', async () => new MyRemoteAdapter()); +``` diff --git a/packages/modules/analytics/docs/collectors.md b/packages/modules/analytics/docs/collectors.md new file mode 100644 index 0000000000..251d547dc3 --- /dev/null +++ b/packages/modules/analytics/docs/collectors.md @@ -0,0 +1,91 @@ +# Collectors + +Collectors implement `IAnalyticsCollector` (or extend `BaseCollector`) and emit +`AnalyticsEvent` objects that are forwarded to all adapters. All collectors +support async initialisation. + +## ContextSelectedCollector + +Emits an event when the active Fusion context changes. Includes the new context, +the previous context, and the current app key in attributes. + +```typescript +builder.setCollector('context-selected', async (args) => { + const ctx = await args.requireInstance('context'); + const app = await args.requireInstance('app'); + return new ContextSelectedCollector(ctx, app); +}); +``` + +## AppSelectedCollector + +Emits an event when the active application changes. Includes the new and +previous app key metadata. + +```typescript +builder.setCollector('app-selected', async (args) => { + const app = await args.requireInstance('app'); + return new AppSelectedCollector(app); +}); +``` + +## AppLoadedCollector + +Emits an event when an application's modules finish loading. Includes app +manifest metadata and the current context (if available). + +```typescript +builder.setCollector('app-loaded', async (args) => { + const event = await args.requireInstance('event'); + const app = await args.requireInstance('app'); + return new AppLoadedCollector(event, app); +}); +``` + +## Creating a Custom Collector + +### Extending `BaseCollector` + +Extend `BaseCollector` with a Zod schema for validation: + +```typescript +import { BaseCollector, createSchema } from '@equinor/fusion-framework-module-analytics/collectors'; +import { z } from 'zod'; +import { of } from 'rxjs'; + +const schema = createSchema(z.string(), z.object({ page: z.string() })); + +class PageViewCollector extends BaseCollector { + constructor() { + super('page-view', schema); + } + + _initialize() { + return of({ value: window.location.pathname, attributes: { page: document.title } }); + } +} +``` + +### Implementing `IAnalyticsCollector` directly + +For cases that don't need schema validation, implement the `Subscribable` contract directly: + +```typescript +import { type AnalyticsEvent, enableAnalytics } from '@equinor/fusion-framework-module-analytics'; +import { Subject } from 'rxjs'; + +const configure = (configurator: IModulesConfigurator) => { + enableAnalytics(configurator, (builder) => { + builder.setCollector('click-test', async () => { + const subject = new Subject(); + window.addEventListener('click', () => { + subject.next({ name: 'window-clicker', value: 42 }); + }); + + return { + subscribe: (subscriber) => subject.subscribe(subscriber), + }; + }); + }); +}; +``` diff --git a/packages/modules/analytics/docs/tracking-events.md b/packages/modules/analytics/docs/tracking-events.md new file mode 100644 index 0000000000..b7e430b2d4 --- /dev/null +++ b/packages/modules/analytics/docs/tracking-events.md @@ -0,0 +1,16 @@ +# Tracking Events Manually + +The provider exposes methods for ad-hoc event tracking outside of collectors: + +```typescript +// Single event +provider.trackAnalytic({ + name: 'button-click', + value: 'save', + attributes: { section: 'toolbar' }, +}); + +// Observable stream +const subscription = provider.trackAnalytic$(myEvent$); +// later: subscription.unsubscribe(); +``` diff --git a/vue-press/src/.vuepress/sidebar.ts b/vue-press/src/.vuepress/sidebar.ts index 1c155062f1..5a4961e345 100644 --- a/vue-press/src/.vuepress/sidebar.ts +++ b/vue-press/src/.vuepress/sidebar.ts @@ -453,6 +453,18 @@ export default sidebar({ text: 'React', link: 'react.md', }, + { + text: 'Adapters', + link: 'docs/adapters.md', + }, + { + text: 'Collectors', + link: 'docs/collectors.md', + }, + { + text: 'Tracking Events Manually', + link: 'docs/tracking-events.md', + }, { text: 'Testing', link: 'docs/testing.md', diff --git a/vue-press/src/modules/analytics/docs/adapters.md b/vue-press/src/modules/analytics/docs/adapters.md new file mode 100644 index 0000000000..5c50b399e9 --- /dev/null +++ b/vue-press/src/modules/analytics/docs/adapters.md @@ -0,0 +1,9 @@ +--- +title: Analytics Adapters +description: ConsoleAnalyticsAdapter, FusionAnalyticsAdapter, and creating a custom IAnalyticsAdapter. +category: Module +tag: + - analytics +--- + + diff --git a/vue-press/src/modules/analytics/docs/collectors.md b/vue-press/src/modules/analytics/docs/collectors.md new file mode 100644 index 0000000000..11fea9ff68 --- /dev/null +++ b/vue-press/src/modules/analytics/docs/collectors.md @@ -0,0 +1,9 @@ +--- +title: Analytics Collectors +description: Built-in collectors (context/app selection, app loaded) and creating a custom IAnalyticsCollector. +category: Module +tag: + - analytics +--- + + diff --git a/vue-press/src/modules/analytics/docs/tracking-events.md b/vue-press/src/modules/analytics/docs/tracking-events.md new file mode 100644 index 0000000000..b6de8b9ffb --- /dev/null +++ b/vue-press/src/modules/analytics/docs/tracking-events.md @@ -0,0 +1,9 @@ +--- +title: Analytics Tracking Events Manually +description: provider.trackAnalytic / trackAnalytic$ for ad-hoc event tracking. +category: Module +tag: + - analytics +--- + + From e0cf2ef257a87ed677d7705ca24ce1fb61e10d5d Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 13:39:17 +0200 Subject: [PATCH 5/6] fix(module-analytics): reject waitForAnalytic when a predicate matcher throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A throwing predicate matcher surfaced on RxJS's error channel, but the subscribe observer had no error handler — the exception was reported globally and the returned promise stayed pending forever. Add an error handler that rejects through the same cleanup path, and cover it with a test. --- .../src/__tests__/MockAnalyticsAdapter.test.ts | 12 ++++++++++++ .../analytics/src/mock/MockAnalyticsAdapter.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts b/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts index 59c7f3da95..2302aa9a24 100644 --- a/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts +++ b/packages/modules/analytics/src/__tests__/MockAnalyticsAdapter.test.ts @@ -103,6 +103,18 @@ describe('MockAnalyticsAdapter', () => { await expect(promise).rejects.toThrow('disposed before a matching event was recorded'); }); + it('rejects instead of hanging when a predicate matcher throws on a future event', async () => { + const adapter = new MockAnalyticsAdapter(); + const boom = new Error('predicate boom'); + + const promise = adapter.waitForAnalytic(() => { + throw boom; + }); + adapter.registerAnalytic(createEvent('button-click')); + + await expect(promise).rejects.toThrow(boom); + }); + it('does not interfere with events recorded by another adapter instance', () => { const adapterA = new MockAnalyticsAdapter(); const adapterB = new MockAnalyticsAdapter(); diff --git a/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts b/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts index a8292eeb42..8156170ca3 100644 --- a/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts +++ b/packages/modules/analytics/src/mock/MockAnalyticsAdapter.ts @@ -123,6 +123,11 @@ export class MockAnalyticsAdapter cleanup(); resolve(event); }, + // A throwing predicate matcher surfaces here instead of hanging the promise forever. + error: (err) => { + cleanup(); + reject(err); + }, complete: () => { cleanup(); reject(new Error('MockAnalyticsAdapter disposed before a matching event was recorded')); From 3bed43935a5d38c88993c303911a998b4c3b46a2 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 13:39:25 +0200 Subject: [PATCH 6/6] docs(module-analytics): fix broken abort and ModulesConfigurator examples in testing.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abort example never called controller.abort() (so copying it hangs indefinitely) and redeclared `event` from the preceding example in the same code block. The bespoke ModulesConfigurator example destructured `analytics` from initialize() without a type — enableAnalytics only registers the module at runtime, so this doesn't typecheck as written; cast to IAnalyticsProvider, matching what the integration test does. --- packages/modules/analytics/docs/testing.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/modules/analytics/docs/testing.md b/packages/modules/analytics/docs/testing.md index 946720c3ce..17b4dd2365 100644 --- a/packages/modules/analytics/docs/testing.md +++ b/packages/modules/analytics/docs/testing.md @@ -41,9 +41,12 @@ Resolves with the first matching event — immediately if one was already record // Rejects after 1000ms if the event never fires const event = await recorder.waitForAnalytic('button-click', { timeout: 1000 }); -// Rejects immediately when the signal aborts +// Rejects as soon as the signal aborts const controller = new AbortController(); -const event = await recorder.waitForAnalytic('button-click', { signal: controller.signal }); +const pending = recorder.waitForAnalytic('button-click', { signal: controller.signal }); +controller.abort(); + +await expect(pending).rejects.toThrow(); ``` ## Using a bespoke `ModulesConfigurator` @@ -54,6 +57,7 @@ const event = await recorder.waitForAnalytic('button-click', { signal: controlle import { ModulesConfigurator } from '@equinor/fusion-framework-module'; import { enableAnalytics } from '@equinor/fusion-framework-module-analytics'; import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock'; +import type { IAnalyticsProvider } from '@equinor/fusion-framework-module-analytics'; const recorder = new MockAnalyticsAdapter(); const configurator = new ModulesConfigurator([]); @@ -62,7 +66,10 @@ enableAnalytics(configurator, (builder) => { builder.setAdapter('mock', async () => recorder); }); -const { analytics } = await configurator.initialize(); +// enableAnalytics only registers the module at runtime, so `initialize()` isn't +// statically typed with an `analytics` property — cast to the real provider type. +const instances = await configurator.initialize(); +const { analytics } = instances as unknown as { analytics: IAnalyticsProvider }; analytics.trackAnalytic({ name: 'button-click', value: 'save' }); expect(recorder.getAnalytics('button-click')).toHaveLength(1);