@equinor/fusion-framework-module-analytics 3.1.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/version.js +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/version.d.ts +1 -1
- package/package.json +11 -8
- package/CHANGELOG.md +0 -386
- package/docs/adapters.md +0 -79
- package/docs/collectors.md +0 -91
- package/docs/testing.md +0 -87
- package/docs/tracking-events.md +0 -16
- package/src/AnalyticsConfigurator.interface.ts +0 -77
- package/src/AnalyticsConfigurator.ts +0 -128
- package/src/AnalyticsProvider.interface.ts +0 -44
- package/src/AnalyticsProvider.ts +0 -150
- package/src/__tests__/MockAnalyticsAdapter.test.ts +0 -127
- package/src/__tests__/mock/analytics-mock-adapter.test.ts +0 -79
- package/src/adapters/AnalyticsAdapter.interface.ts +0 -36
- package/src/adapters/ConsoleAnalyticsAdapter.ts +0 -37
- package/src/adapters/FusionAnalyticsAdapter.ts +0 -95
- package/src/adapters/index.ts +0 -11
- package/src/collectors/AnalyticsCollector.interface.ts +0 -27
- package/src/collectors/AppLoadedCollector.ts +0 -94
- package/src/collectors/AppSelectedCollector.ts +0 -70
- package/src/collectors/ContextSelectedCollector.ts +0 -83
- package/src/collectors/create-schema.ts +0 -123
- package/src/collectors/index.ts +0 -12
- package/src/collectors/utils/app-key-schema.ts +0 -17
- package/src/collectors/utils/app-schema.ts +0 -22
- package/src/collectors/utils/context-schema.ts +0 -22
- package/src/collectors/utils/extract-app-key-metadata.ts +0 -15
- package/src/collectors/utils/extract-app-metadata.ts +0 -20
- package/src/collectors/utils/extract-context-metadata.ts +0 -19
- package/src/enable-analytics.ts +0 -44
- package/src/index.ts +0 -30
- package/src/logExporters/fusionOTLPLogExporter/FusionOTLPLogExporter.ts +0 -71
- package/src/logExporters/fusionOTLPLogExporter/HttpClientExporterTransport.ts +0 -123
- package/src/logExporters/index.ts +0 -10
- package/src/mock/MockAnalyticsAdapter.ts +0 -178
- package/src/mock/index.ts +0 -29
- package/src/module.ts +0 -59
- package/src/types.ts +0 -60
- package/src/version.ts +0 -2
- package/tsconfig.json +0 -29
- package/vitest.config.ts +0 -11
package/docs/testing.md
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# Testing
|
|
2
|
-
|
|
3
|
-
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:
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
import { enableAnalytics } from '@equinor/fusion-framework-module-analytics';
|
|
7
|
-
import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock';
|
|
8
|
-
|
|
9
|
-
const recorder = new MockAnalyticsAdapter();
|
|
10
|
-
|
|
11
|
-
enableAnalytics(configurator, (builder) => {
|
|
12
|
-
builder.setAdapter('mock', async () => recorder);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// ... exercise the app under test, then assert:
|
|
16
|
-
const event = await recorder.waitForAnalytic('button-click');
|
|
17
|
-
expect(event.attributes?.section).toBe('header');
|
|
18
|
-
|
|
19
|
-
// or synchronously inspect everything recorded so far:
|
|
20
|
-
expect(recorder.getAnalytics('page-view')).toHaveLength(1);
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
`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.
|
|
24
|
-
|
|
25
|
-
## `getAnalytics(matcher?)`
|
|
26
|
-
|
|
27
|
-
Returns recorded events synchronously, filtered by an event name, an array of names, or a predicate. Omit the matcher to get every recorded event.
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
recorder.getAnalytics(); // every recorded event
|
|
31
|
-
recorder.getAnalytics('button-click'); // by name
|
|
32
|
-
recorder.getAnalytics(['button-click', 'page-view']); // any of these names
|
|
33
|
-
recorder.getAnalytics((event) => event.attributes?.section === 'header'); // predicate
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## `waitForAnalytic(matcher, options?)`
|
|
37
|
-
|
|
38
|
-
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.
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
// Rejects after 1000ms if the event never fires
|
|
42
|
-
const event = await recorder.waitForAnalytic('button-click', { timeout: 1000 });
|
|
43
|
-
|
|
44
|
-
// Rejects as soon as the signal aborts
|
|
45
|
-
const controller = new AbortController();
|
|
46
|
-
const pending = recorder.waitForAnalytic('button-click', { signal: controller.signal });
|
|
47
|
-
controller.abort();
|
|
48
|
-
|
|
49
|
-
await expect(pending).rejects.toThrow();
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Using a bespoke `ModulesConfigurator`
|
|
53
|
-
|
|
54
|
-
`MockAnalyticsAdapter` works the same way with a manually composed set of modules — no app or portal host required:
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { ModulesConfigurator } from '@equinor/fusion-framework-module';
|
|
58
|
-
import { enableAnalytics } from '@equinor/fusion-framework-module-analytics';
|
|
59
|
-
import { MockAnalyticsAdapter } from '@equinor/fusion-framework-module-analytics/mock';
|
|
60
|
-
import type { IAnalyticsProvider } from '@equinor/fusion-framework-module-analytics';
|
|
61
|
-
|
|
62
|
-
const recorder = new MockAnalyticsAdapter();
|
|
63
|
-
const configurator = new ModulesConfigurator([]);
|
|
64
|
-
|
|
65
|
-
enableAnalytics(configurator, (builder) => {
|
|
66
|
-
builder.setAdapter('mock', async () => recorder);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// enableAnalytics only registers the module at runtime, so `initialize()` isn't
|
|
70
|
-
// statically typed with an `analytics` property — cast to the real provider type.
|
|
71
|
-
const instances = await configurator.initialize();
|
|
72
|
-
const { analytics } = instances as unknown as { analytics: IAnalyticsProvider };
|
|
73
|
-
analytics.trackAnalytic({ name: 'button-click', value: 'save' });
|
|
74
|
-
|
|
75
|
-
expect(recorder.getAnalytics('button-click')).toHaveLength(1);
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## Disposal
|
|
79
|
-
|
|
80
|
-
`MockAnalyticsAdapter` completes its internal event stream on `[Symbol.dispose]()`, rejecting any pending `waitForAnalytic` calls instead of leaving them hanging:
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
const pending = recorder.waitForAnalytic('button-click');
|
|
84
|
-
recorder[Symbol.dispose]();
|
|
85
|
-
|
|
86
|
-
await expect(pending).rejects.toThrow('disposed before a matching event was recorded');
|
|
87
|
-
```
|
package/docs/tracking-events.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# Tracking Events Manually
|
|
2
|
-
|
|
3
|
-
The provider exposes methods for ad-hoc event tracking outside of collectors:
|
|
4
|
-
|
|
5
|
-
```typescript
|
|
6
|
-
// Single event
|
|
7
|
-
provider.trackAnalytic({
|
|
8
|
-
name: 'button-click',
|
|
9
|
-
value: 'save',
|
|
10
|
-
attributes: { section: 'toolbar' },
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
// Observable stream
|
|
14
|
-
const subscription = provider.trackAnalytic$(myEvent$);
|
|
15
|
-
// later: subscription.unsubscribe();
|
|
16
|
-
```
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { ConfigBuilderCallback } from '@equinor/fusion-framework-module';
|
|
2
|
-
import type { IAnalyticsCollector } from './collectors/AnalyticsCollector.interface.js';
|
|
3
|
-
import type { IAnalyticsAdapter } from './adapters/AnalyticsAdapter.interface.js';
|
|
4
|
-
import type { AnalyticsEvent } from './types.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Resolved analytics configuration containing the instantiated collectors and adapters.
|
|
8
|
-
*
|
|
9
|
-
* @remarks
|
|
10
|
-
* This type is the output of the configuration stage. The module’s `initialize`
|
|
11
|
-
* function receives the resolved `AnalyticsConfig` and passes it to
|
|
12
|
-
* {@link AnalyticsProvider}.
|
|
13
|
-
*/
|
|
14
|
-
export type AnalyticsConfig = {
|
|
15
|
-
/** Record of analytics collectors keyed by a unique identifier. */
|
|
16
|
-
collectors: Record<string, IAnalyticsCollector>;
|
|
17
|
-
/** Record of analytics adapters keyed by a unique identifier. */
|
|
18
|
-
adapters: Record<string, IAnalyticsAdapter>;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Configuration-time interface for registering analytics adapters and collectors.
|
|
23
|
-
*
|
|
24
|
-
* @remarks
|
|
25
|
-
* Obtain an instance of this interface inside the callback passed to
|
|
26
|
-
* {@link enableAnalytics}. Use {@link IAnalyticsConfigurator.setAdapter | setAdapter}
|
|
27
|
-
* and {@link IAnalyticsConfigurator.setCollector | setCollector} to register
|
|
28
|
-
* components. Both methods support method chaining.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```ts
|
|
32
|
-
* enableAnalytics(configurator, (builder) => {
|
|
33
|
-
* builder
|
|
34
|
-
* .setAdapter('console', async () => new ConsoleAnalyticsAdapter())
|
|
35
|
-
* .setCollector('context-selected', async (args) => {
|
|
36
|
-
* const ctx = await args.requireInstance('context');
|
|
37
|
-
* const app = await args.requireInstance('app');
|
|
38
|
-
* return new ContextSelectedCollector(ctx, app);
|
|
39
|
-
* });
|
|
40
|
-
* });
|
|
41
|
-
* ```
|
|
42
|
-
*/
|
|
43
|
-
export interface IAnalyticsConfigurator {
|
|
44
|
-
/**
|
|
45
|
-
* Registers an analytics collector factory identified by a unique key.
|
|
46
|
-
*
|
|
47
|
-
* The factory callback receives module-resolution arguments so it can
|
|
48
|
-
* resolve dependencies (e.g. context or app providers) before returning
|
|
49
|
-
* the collector instance.
|
|
50
|
-
*
|
|
51
|
-
* @template T - The concrete analytics event type the collector emits.
|
|
52
|
-
* @param identifier - Unique key for this collector.
|
|
53
|
-
* @param callBack - Async factory that returns the collector instance.
|
|
54
|
-
* @returns The configurator instance for method chaining.
|
|
55
|
-
*/
|
|
56
|
-
setCollector<T extends AnalyticsEvent>(
|
|
57
|
-
identifier: string,
|
|
58
|
-
callBack: ConfigBuilderCallback<IAnalyticsCollector<T>>,
|
|
59
|
-
): this;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Registers an analytics adapter factory identified by a unique key.
|
|
63
|
-
*
|
|
64
|
-
* The factory callback receives module-resolution arguments so it can
|
|
65
|
-
* resolve dependencies (e.g. service-discovery HTTP clients) before
|
|
66
|
-
* returning the adapter instance.
|
|
67
|
-
*
|
|
68
|
-
* @template T - The concrete analytics event type the adapter handles.
|
|
69
|
-
* @param identifier - Unique key for this adapter.
|
|
70
|
-
* @param callback - Async factory that returns the adapter instance.
|
|
71
|
-
* @returns The configurator instance for method chaining.
|
|
72
|
-
*/
|
|
73
|
-
setAdapter<T extends AnalyticsEvent>(
|
|
74
|
-
identifier: string,
|
|
75
|
-
callback: ConfigBuilderCallback<IAnalyticsAdapter<T>>,
|
|
76
|
-
): this;
|
|
77
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BaseConfigBuilder,
|
|
3
|
-
type ConfigBuilderCallbackArgs,
|
|
4
|
-
type ConfigBuilderCallback,
|
|
5
|
-
} from '@equinor/fusion-framework-module';
|
|
6
|
-
import type { IAnalyticsConfigurator, AnalyticsConfig } from './AnalyticsConfigurator.interface.js';
|
|
7
|
-
import type { IAnalyticsCollector } from './collectors/AnalyticsCollector.interface.js';
|
|
8
|
-
import type { IAnalyticsAdapter } from './adapters/AnalyticsAdapter.interface.js';
|
|
9
|
-
import type { AnalyticsEvent } from './types.js';
|
|
10
|
-
import { from, type ObservableInput } from 'rxjs';
|
|
11
|
-
|
|
12
|
-
import { map, scan, filter, defaultIfEmpty, shareReplay, mergeMap } from 'rxjs/operators';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Default {@link IAnalyticsConfigurator} implementation for registering analytics adapters and collectors.
|
|
16
|
-
*
|
|
17
|
-
* @remarks
|
|
18
|
-
* Extends `BaseConfigBuilder` to resolve adapter and collector factories
|
|
19
|
-
* asynchronously at module-initialisation time using RxJS `mergeMap`. Each
|
|
20
|
-
* registered factory is invoked with the module’s `ConfigBuilderCallbackArgs`
|
|
21
|
-
* so it can resolve dependencies from the framework before returning its
|
|
22
|
-
* adapter or collector instance.
|
|
23
|
-
*
|
|
24
|
-
* Use {@link AnalyticsConfigurator.setAdapter | setAdapter} and
|
|
25
|
-
* {@link AnalyticsConfigurator.setCollector | setCollector} to register
|
|
26
|
-
* components. Both methods return `this` for fluent chaining.
|
|
27
|
-
*
|
|
28
|
-
* @see {@link IAnalyticsConfigurator}
|
|
29
|
-
* @see {@link BaseConfigBuilder}
|
|
30
|
-
*/
|
|
31
|
-
export class AnalyticsConfigurator
|
|
32
|
-
extends BaseConfigBuilder<AnalyticsConfig>
|
|
33
|
-
implements IAnalyticsConfigurator
|
|
34
|
-
{
|
|
35
|
-
#collectorCallbacks: Record<string, ConfigBuilderCallback<IAnalyticsCollector>> = {};
|
|
36
|
-
#adapterCallbacks: Record<string, ConfigBuilderCallback<IAnalyticsAdapter>> = {};
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Registers the async resolvers for `collectors` and `adapters` config keys.
|
|
40
|
-
*/
|
|
41
|
-
constructor() {
|
|
42
|
-
super();
|
|
43
|
-
|
|
44
|
-
// Configure async collectors resolution using mergeMap to handle Promise/Observable collector factories
|
|
45
|
-
this._set(
|
|
46
|
-
'collectors',
|
|
47
|
-
(args: ConfigBuilderCallbackArgs): ObservableInput<Record<string, IAnalyticsCollector>> => {
|
|
48
|
-
// Resolve each collector factory and drop any that resolve to a falsy value
|
|
49
|
-
return from(Object.entries(this.#collectorCallbacks)).pipe(
|
|
50
|
-
mergeMap(([identifier, collectorFn]) =>
|
|
51
|
-
// Resolve the collector's own Promise/Observable factory result
|
|
52
|
-
from(collectorFn(args)).pipe(
|
|
53
|
-
filter((collector): collector is IAnalyticsCollector => !!collector),
|
|
54
|
-
map((collector) => [identifier, collector] as const),
|
|
55
|
-
),
|
|
56
|
-
),
|
|
57
|
-
scan(
|
|
58
|
-
(acc, [identifier, collector]) => {
|
|
59
|
-
acc[identifier] = collector;
|
|
60
|
-
return acc;
|
|
61
|
-
},
|
|
62
|
-
{} as Record<string, IAnalyticsCollector>,
|
|
63
|
-
),
|
|
64
|
-
defaultIfEmpty({}),
|
|
65
|
-
shareReplay({ bufferSize: 1, refCount: true }),
|
|
66
|
-
);
|
|
67
|
-
},
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
// Configure async adapters resolution using mergeMap to handle Promise/Observable adapter factories
|
|
71
|
-
this._set(
|
|
72
|
-
'adapters',
|
|
73
|
-
(args: ConfigBuilderCallbackArgs): ObservableInput<Record<string, IAnalyticsAdapter>> => {
|
|
74
|
-
// Resolve each adapter factory and drop any that resolve to a falsy value
|
|
75
|
-
return from(Object.entries(this.#adapterCallbacks)).pipe(
|
|
76
|
-
mergeMap(([identifier, adapterFn]) =>
|
|
77
|
-
// Resolve the adapter's own Promise/Observable factory result
|
|
78
|
-
from(adapterFn(args)).pipe(
|
|
79
|
-
filter((adapter): adapter is IAnalyticsAdapter => !!adapter),
|
|
80
|
-
map((adapter) => [identifier, adapter] as const),
|
|
81
|
-
),
|
|
82
|
-
),
|
|
83
|
-
scan(
|
|
84
|
-
(acc, [identifier, adapter]) => {
|
|
85
|
-
acc[identifier] = adapter;
|
|
86
|
-
return acc;
|
|
87
|
-
},
|
|
88
|
-
{} as Record<string, IAnalyticsAdapter>,
|
|
89
|
-
),
|
|
90
|
-
defaultIfEmpty({}),
|
|
91
|
-
shareReplay({ bufferSize: 1, refCount: true }),
|
|
92
|
-
);
|
|
93
|
-
},
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Registers an analytics collector factory.
|
|
99
|
-
*
|
|
100
|
-
* @template T - The concrete analytics event type the collector emits.
|
|
101
|
-
* @param identifier - Unique key for the collector.
|
|
102
|
-
* @param callback - Factory that receives module-resolution args and returns a collector.
|
|
103
|
-
* @returns `this` for method chaining.
|
|
104
|
-
*/
|
|
105
|
-
setCollector<T extends AnalyticsEvent>(
|
|
106
|
-
identifier: string,
|
|
107
|
-
callback: ConfigBuilderCallback<IAnalyticsCollector<T>>,
|
|
108
|
-
): this {
|
|
109
|
-
this.#collectorCallbacks[identifier] = callback;
|
|
110
|
-
return this;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Registers an analytics adapter factory.
|
|
115
|
-
*
|
|
116
|
-
* @template T - The concrete analytics event type the adapter handles.
|
|
117
|
-
* @param identifier - Unique key for the adapter.
|
|
118
|
-
* @param callback - Factory that receives module-resolution args and returns an adapter.
|
|
119
|
-
* @returns `this` for method chaining.
|
|
120
|
-
*/
|
|
121
|
-
setAdapter<T extends AnalyticsEvent>(
|
|
122
|
-
identifier: string,
|
|
123
|
-
callback: ConfigBuilderCallback<IAnalyticsAdapter<T>>,
|
|
124
|
-
): this {
|
|
125
|
-
this.#adapterCallbacks[identifier] = callback;
|
|
126
|
-
return this;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ObservableInput, Subscription } from 'rxjs';
|
|
2
|
-
import type { AnalyticsEvent } from './types.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Public interface for the analytics provider exposed by the `analytics` module.
|
|
6
|
-
*
|
|
7
|
-
* @remarks
|
|
8
|
-
* Use `trackAnalytic` to push a single event or `trackAnalytic$` to forward
|
|
9
|
-
* an observable stream of events. Both methods route events to all registered
|
|
10
|
-
* adapters.
|
|
11
|
-
*/
|
|
12
|
-
export interface IAnalyticsProvider {
|
|
13
|
-
/**
|
|
14
|
-
* Pushes a single analytics event to all registered adapters.
|
|
15
|
-
*
|
|
16
|
-
* @param event - The analytics event to track.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* provider.trackAnalytic({
|
|
21
|
-
* name: 'button-click',
|
|
22
|
-
* value: 'save',
|
|
23
|
-
* attributes: { page: 'settings' },
|
|
24
|
-
* });
|
|
25
|
-
* ```
|
|
26
|
-
*/
|
|
27
|
-
trackAnalytic(event: AnalyticsEvent): void;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Subscribes to an observable stream of analytics events and forwards each
|
|
31
|
-
* emission to all registered adapters.
|
|
32
|
-
*
|
|
33
|
-
* @param analytic$ - Observable input stream of analytics events.
|
|
34
|
-
* @returns A combined `Disposable & Subscription` handle for cleanup.
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```ts
|
|
38
|
-
* const sub = provider.trackAnalytic$(myEvent$);
|
|
39
|
-
* // later
|
|
40
|
-
* sub.unsubscribe();
|
|
41
|
-
* ```
|
|
42
|
-
*/
|
|
43
|
-
trackAnalytic$(analytic$: ObservableInput<AnalyticsEvent>): Disposable & Subscription;
|
|
44
|
-
}
|
package/src/AnalyticsProvider.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { BaseModuleProvider } from '@equinor/fusion-framework-module/provider';
|
|
2
|
-
|
|
3
|
-
import { version } from './version.js';
|
|
4
|
-
|
|
5
|
-
import type { AnalyticsConfig } from './AnalyticsConfigurator.interface.js';
|
|
6
|
-
import type { IAnalyticsProvider } from './AnalyticsProvider.interface.js';
|
|
7
|
-
import type { AnalyticsEvent } from './types.js';
|
|
8
|
-
import { from, type ObservableInput, Subject, Subscription } from 'rxjs';
|
|
9
|
-
import type { IAnalyticsCollector } from './collectors/AnalyticsCollector.interface.js';
|
|
10
|
-
import type { IAnalyticsAdapter } from './adapters/AnalyticsAdapter.interface.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* An RxJS `Subscription` that also implements the TC39 `Disposable` protocol.
|
|
14
|
-
*
|
|
15
|
-
* @remarks
|
|
16
|
-
* Returned by {@link AnalyticsProvider.trackAnalytic$} so callers can clean up
|
|
17
|
-
* with either `subscription.unsubscribe()` or `using` / `Symbol.dispose`.
|
|
18
|
-
*/
|
|
19
|
-
class DisposableSubscription extends Subscription {
|
|
20
|
-
/**
|
|
21
|
-
* @param subscription - The underlying subscription to wrap with `Symbol.dispose` support.
|
|
22
|
-
*/
|
|
23
|
-
constructor(subscription: Subscription) {
|
|
24
|
-
super(subscription.unsubscribe);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** TC39 `Disposable` protocol support, delegating to `unsubscribe()`. */
|
|
28
|
-
[Symbol.dispose] = () => {
|
|
29
|
-
this.unsubscribe();
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Runtime analytics provider that collects events from collectors and dispatches
|
|
35
|
-
* them to adapters.
|
|
36
|
-
*
|
|
37
|
-
* @remarks
|
|
38
|
-
* Created by the analytics module during initialisation. The provider:
|
|
39
|
-
*
|
|
40
|
-
* 1. Initialises all registered adapters and collectors.
|
|
41
|
-
* 2. Subscribes to every collector and merges their events into a shared stream.
|
|
42
|
-
* 3. Forwards each emitted event to every registered adapter via
|
|
43
|
-
* {@link IAnalyticsAdapter.registerAnalytic}.
|
|
44
|
-
*
|
|
45
|
-
* Consumers can also push ad-hoc events with {@link AnalyticsProvider.trackAnalytic}
|
|
46
|
-
* or subscribe an observable stream with {@link AnalyticsProvider.trackAnalytic$}.
|
|
47
|
-
*
|
|
48
|
-
* @extends BaseModuleProvider<AnalyticsConfig>
|
|
49
|
-
* @implements IAnalyticsProvider
|
|
50
|
-
*/
|
|
51
|
-
export class AnalyticsProvider
|
|
52
|
-
extends BaseModuleProvider<AnalyticsConfig>
|
|
53
|
-
implements IAnalyticsProvider
|
|
54
|
-
{
|
|
55
|
-
#analytics: Subject<AnalyticsEvent>;
|
|
56
|
-
#collectors: Record<string, IAnalyticsCollector>;
|
|
57
|
-
#adapters: Record<string, IAnalyticsAdapter>;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param config - Resolved analytics module configuration, including collectors and adapters.
|
|
61
|
-
*/
|
|
62
|
-
constructor(config: AnalyticsConfig) {
|
|
63
|
-
super({ version, config });
|
|
64
|
-
|
|
65
|
-
this.#analytics = new Subject();
|
|
66
|
-
this.#collectors = config.collectors;
|
|
67
|
-
this.#adapters = config.adapters;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Initialises all adapters and collectors, then wires collector output into
|
|
72
|
-
* the adapter pipeline.
|
|
73
|
-
*
|
|
74
|
-
* @remarks
|
|
75
|
-
* Initialisation is idempotent within the module lifecycle — the module calls
|
|
76
|
-
* this once during `module.initialize`. Steps:
|
|
77
|
-
*
|
|
78
|
-
* 1. Initialise all collectors (via `Promise.allSettled`).
|
|
79
|
-
* 2. Initialise all adapters (via `Promise.allSettled`).
|
|
80
|
-
* 3. Subscribe to each collector and forward events into the shared subject.
|
|
81
|
-
* 4. Subscribe to the shared subject and dispatch to every adapter.
|
|
82
|
-
*
|
|
83
|
-
* All subscriptions are registered as teardowns on the base provider.
|
|
84
|
-
*
|
|
85
|
-
* @returns A promise that resolves when all adapters and collectors are initialised.
|
|
86
|
-
*/
|
|
87
|
-
async initialize(): Promise<void> {
|
|
88
|
-
// Kick off initialization for every collector, tolerating individual failures
|
|
89
|
-
const initializedCollectors = Object.values(this.#collectors).map((collector) =>
|
|
90
|
-
Promise.resolve(collector.initialize?.()),
|
|
91
|
-
);
|
|
92
|
-
// Kick off initialization for every adapter, tolerating individual failures
|
|
93
|
-
const initializedAdapters = Object.values(this.#adapters).map((adapters) =>
|
|
94
|
-
Promise.resolve(adapters.initialize?.()),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
await Promise.allSettled(initializedCollectors);
|
|
98
|
-
await Promise.allSettled(initializedAdapters);
|
|
99
|
-
|
|
100
|
-
// Forward every collector's emissions into the shared analytics subject
|
|
101
|
-
for (const collector of Object.values(this.#collectors)) {
|
|
102
|
-
const subscription = collector.subscribe({
|
|
103
|
-
next: (event) => {
|
|
104
|
-
this.#analytics.next(event);
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
this._addTeardown(subscription);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const adapterSubscription = this.#analytics.subscribe({
|
|
112
|
-
next: (event) => {
|
|
113
|
-
// Dispatch every emitted event to all registered adapters
|
|
114
|
-
for (const adapter of Object.values(this.#adapters)) {
|
|
115
|
-
adapter.registerAnalytic(event);
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
this._addTeardown(adapterSubscription);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Pushes a single analytics event to all registered adapters.
|
|
124
|
-
*
|
|
125
|
-
* @param event - The analytics event to track.
|
|
126
|
-
*/
|
|
127
|
-
trackAnalytic(event: AnalyticsEvent): void {
|
|
128
|
-
// TODO(#5098): Validate AnalyticsEvent includes name, value and attributes
|
|
129
|
-
this.#analytics.next(event);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Subscribes to an observable stream of analytics events and forwards each
|
|
134
|
-
* emission to all registered adapters.
|
|
135
|
-
*
|
|
136
|
-
* @param analytic$ - Observable input stream of analytics events.
|
|
137
|
-
* @returns A {@link DisposableSubscription} supporting both `unsubscribe()` and `Symbol.dispose`.
|
|
138
|
-
*/
|
|
139
|
-
trackAnalytic$(analytic$: ObservableInput<AnalyticsEvent>): Disposable & Subscription {
|
|
140
|
-
const subscription = from(analytic$)
|
|
141
|
-
// TODO(#5098): Validate AnalyticsEvent includes name, value and attributes
|
|
142
|
-
.subscribe({
|
|
143
|
-
next: (event) => {
|
|
144
|
-
this.#analytics.next(event);
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
return new DisposableSubscription(subscription);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { MockAnalyticsAdapter } from '../mock/MockAnalyticsAdapter.js';
|
|
4
|
-
import type { AnalyticsEvent } from '../types.js';
|
|
5
|
-
|
|
6
|
-
const createEvent = (name: string, overrides: Partial<AnalyticsEvent> = {}): AnalyticsEvent => ({
|
|
7
|
-
name,
|
|
8
|
-
value: null,
|
|
9
|
-
...overrides,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
describe('MockAnalyticsAdapter', () => {
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
vi.useRealTimers();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('records events via registerAnalytic and returns them from getAnalytics', () => {
|
|
18
|
-
const adapter = new MockAnalyticsAdapter();
|
|
19
|
-
|
|
20
|
-
adapter.registerAnalytic(createEvent('button-click'));
|
|
21
|
-
adapter.registerAnalytic(createEvent('page-view'));
|
|
22
|
-
|
|
23
|
-
expect(adapter.getAnalytics().map((e) => e.name)).toEqual(['button-click', 'page-view']);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('filters getAnalytics by a single name, an array, and a predicate', () => {
|
|
27
|
-
const adapter = new MockAnalyticsAdapter();
|
|
28
|
-
adapter.registerAnalytic(createEvent('button-click', { attributes: { section: 'header' } }));
|
|
29
|
-
adapter.registerAnalytic(createEvent('page-view'));
|
|
30
|
-
|
|
31
|
-
expect(adapter.getAnalytics('button-click')).toHaveLength(1);
|
|
32
|
-
expect(adapter.getAnalytics(['button-click', 'page-view'])).toHaveLength(2);
|
|
33
|
-
expect(adapter.getAnalytics((e) => e.attributes?.section === 'header')).toHaveLength(1);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('resolves waitForAnalytic immediately when a matching event is already recorded', async () => {
|
|
37
|
-
const adapter = new MockAnalyticsAdapter();
|
|
38
|
-
adapter.registerAnalytic(createEvent('button-click'));
|
|
39
|
-
|
|
40
|
-
const event = await adapter.waitForAnalytic('button-click');
|
|
41
|
-
|
|
42
|
-
expect(event.name).toBe('button-click');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('resolves waitForAnalytic when a matching event is recorded later', async () => {
|
|
46
|
-
const adapter = new MockAnalyticsAdapter();
|
|
47
|
-
|
|
48
|
-
const promise = adapter.waitForAnalytic('button-click');
|
|
49
|
-
adapter.registerAnalytic(createEvent('page-view'));
|
|
50
|
-
adapter.registerAnalytic(createEvent('button-click'));
|
|
51
|
-
const event = await promise;
|
|
52
|
-
|
|
53
|
-
expect(event.name).toBe('button-click');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('resolves waitForAnalytic via predicate matcher', async () => {
|
|
57
|
-
const adapter = new MockAnalyticsAdapter();
|
|
58
|
-
|
|
59
|
-
const promise = adapter.waitForAnalytic((e) => e.attributes?.id === 42);
|
|
60
|
-
adapter.registerAnalytic(createEvent('button-click', { attributes: { id: 1 } }));
|
|
61
|
-
adapter.registerAnalytic(createEvent('button-click', { attributes: { id: 42 } }));
|
|
62
|
-
const event = await promise;
|
|
63
|
-
|
|
64
|
-
expect(event.attributes?.id).toBe(42);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('rejects when the timeout elapses before a matching event is recorded', async () => {
|
|
68
|
-
vi.useFakeTimers();
|
|
69
|
-
const adapter = new MockAnalyticsAdapter();
|
|
70
|
-
|
|
71
|
-
const promise = adapter.waitForAnalytic('button-click', { timeout: 500 });
|
|
72
|
-
vi.advanceTimersByTime(501);
|
|
73
|
-
|
|
74
|
-
await expect(promise).rejects.toThrow('waitForAnalytic timed out after 500ms');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('rejects when the AbortSignal fires before a matching event', async () => {
|
|
78
|
-
const adapter = new MockAnalyticsAdapter();
|
|
79
|
-
const controller = new AbortController();
|
|
80
|
-
|
|
81
|
-
const promise = adapter.waitForAnalytic('button-click', { signal: controller.signal });
|
|
82
|
-
controller.abort();
|
|
83
|
-
|
|
84
|
-
await expect(promise).rejects.toThrow();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('rejects immediately when passed an already-aborted signal', async () => {
|
|
88
|
-
const adapter = new MockAnalyticsAdapter();
|
|
89
|
-
const controller = new AbortController();
|
|
90
|
-
controller.abort();
|
|
91
|
-
|
|
92
|
-
await expect(
|
|
93
|
-
adapter.waitForAnalytic('button-click', { signal: controller.signal }),
|
|
94
|
-
).rejects.toThrow();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('rejects pending waitForAnalytic calls when the adapter is disposed', async () => {
|
|
98
|
-
const adapter = new MockAnalyticsAdapter();
|
|
99
|
-
|
|
100
|
-
const promise = adapter.waitForAnalytic('button-click');
|
|
101
|
-
adapter[Symbol.dispose]();
|
|
102
|
-
|
|
103
|
-
await expect(promise).rejects.toThrow('disposed before a matching event was recorded');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('rejects instead of hanging when a predicate matcher throws on a future event', async () => {
|
|
107
|
-
const adapter = new MockAnalyticsAdapter();
|
|
108
|
-
const boom = new Error('predicate boom');
|
|
109
|
-
|
|
110
|
-
const promise = adapter.waitForAnalytic(() => {
|
|
111
|
-
throw boom;
|
|
112
|
-
});
|
|
113
|
-
adapter.registerAnalytic(createEvent('button-click'));
|
|
114
|
-
|
|
115
|
-
await expect(promise).rejects.toThrow(boom);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('does not interfere with events recorded by another adapter instance', () => {
|
|
119
|
-
const adapterA = new MockAnalyticsAdapter();
|
|
120
|
-
const adapterB = new MockAnalyticsAdapter();
|
|
121
|
-
|
|
122
|
-
adapterA.registerAnalytic(createEvent('button-click'));
|
|
123
|
-
|
|
124
|
-
expect(adapterA.getAnalytics()).toHaveLength(1);
|
|
125
|
-
expect(adapterB.getAnalytics()).toHaveLength(0);
|
|
126
|
-
});
|
|
127
|
-
});
|