Skip to content

Commit 42b9d8a

Browse files
committed
feat: add user activity tracking for embedded apps
1 parent 67ab095 commit 42b9d8a

19 files changed

+1836
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** The user activity message type used for communication between shell and iframes */
2+
export const USER_ACTIVITY_MESSAGE_TYPE = 'user_activity';
3+
4+
/**
5+
* Types of user activity events that can be tracked
6+
*/
7+
export type UserActivityEventType =
8+
| 'click'
9+
| 'keydown'
10+
| 'scroll'
11+
| 'touchstart'
12+
| 'focus'
13+
| 'visibilitychange'
14+
| 'iframeinteraction';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type {
2+
UserActivityMessageV1_0,
3+
} from './user-activity-versions';
4+
5+
/** The versions of user activity messages */
6+
export type UserActivityMessage = UserActivityMessageV1_0;
7+
export * from './user-activity-versions';
8+
export { USER_ACTIVITY_MESSAGE_TYPE, type UserActivityEventType } from './base';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type {
2+
VersionedMessage,
3+
} from '@amadeus-it-group/microfrontends';
4+
import type {
5+
USER_ACTIVITY_MESSAGE_TYPE,
6+
UserActivityEventType,
7+
} from './base';
8+
9+
/**
10+
* User activity message version 1.0
11+
* Sent from embedded modules to the shell to indicate user interaction
12+
*/
13+
export interface UserActivityMessageV1_0 extends VersionedMessage {
14+
/** @inheritdoc */
15+
type: typeof USER_ACTIVITY_MESSAGE_TYPE;
16+
/** @inheritdoc */
17+
version: '1.0';
18+
/** The type of activity event that occurred */
19+
eventType: UserActivityEventType;
20+
/** Timestamp when the activity occurred */
21+
timestamp: number;
22+
}

packages/@ama-mfe/messages/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './messages/history/index';
22
export * from './messages/navigation/index';
33
export * from './messages/resize/index';
44
export * from './messages/theme/index';
5+
export * from './messages/user-activity/index';

packages/@ama-mfe/ng-utils/README.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,157 @@ The host information is stored in session storage so it won't be lost when navig
295295
When using iframes to embed applications, the browser history might be shared by the main page and the embedded iframe. For example `<iframe src="https://example.com" sandbox="allow-same-origin">` will share the same history as the main page. This can lead to unexpected behavior when using browser 'back' and 'forward' buttons.
296296

297297
To avoid this, the `@ama-mfe/ng-utils` will forbid the application running in the iframe to alter the browser history. It will happen when connection is configured using `provideConection()` function. This will prevent the iframe to be able to use the `history.pushState` and `history.replaceState` methods.
298+
299+
### User Activity Tracking
300+
301+
The User Activity Tracking feature allows a host application (shell) to monitor user interactions across embedded micro-frontends. This is useful for implementing session timeout functionality, analytics, or any feature that needs to know when users are actively interacting with the application.
302+
303+
#### How it works
304+
305+
- **Producer**: The `ActivityProducerService` listens for DOM events (click, keydown, scroll, touchstart, focus) and:
306+
- Exposes a `localActivity` signal for consumers within the same application (not throttled for local detection).
307+
- Sends throttled activity messages via the communication protocol to connected peers.
308+
- **Consumer**: The `ActivityConsumerService` receives activity messages from connected peers via the communication protocol and exposes them via the `latestReceivedActivity` signal.
309+
310+
Both services can be used in either the shell (host) or embedded applications depending on your use case. For example:
311+
- A shell can produce activity signals to notify embedded modules of user interactions in the host.
312+
- An embedded module can consume activity signals from the shell.
313+
- An application can use the producer's `localActivity` signal to detect activity locally.
314+
315+
The service automatically throttles messages sent to peers to prevent flooding the communication channel. High-frequency events like `scroll` have additional throttling.
316+
317+
#### Producer Configuration
318+
319+
Start the `ActivityProducerService` to send activity signals to connected peers:
320+
321+
```typescript
322+
import { inject, runInInjectionContext } from '@angular/core';
323+
import { bootstrapApplication } from '@angular/platform-browser';
324+
import { ConnectionService, ActivityProducerService } from '@ama-mfe/ng-utils';
325+
326+
bootstrapApplication(App, appConfig)
327+
.then((m) => {
328+
runInInjectionContext(m.injector, () => {
329+
if (window.top !== window.self) {
330+
inject(ConnectionService).connect('hostUniqueID');
331+
// Start activity tracking with custom throttle
332+
inject(ActivityProducerService).start({
333+
throttleMs: 5000 // Send at most one message every 5 seconds
334+
});
335+
}
336+
});
337+
});
338+
```
339+
340+
##### Configuration Options
341+
342+
| Option | Type | Default | Description |
343+
|--------|------|---------|-------------|
344+
| `throttleMs` | `number` | `1000` | Minimum interval between activity messages sent to the host |
345+
| `trackNestedIframes` | `boolean` | `false` | Enable tracking of nested iframes within the application |
346+
| `nestedIframePollIntervalMs` | `number` | `1000` | Polling interval for detecting iframe focus changes |
347+
| `nestedIframeActivityEmitIntervalMs` | `number` | `30000` | Interval for sending activity signals while an iframe has focus |
348+
| `highFrequencyThrottleMs` | `number` | `300` | Throttle time for high-frequency events (scroll) |
349+
| `shouldBroadcast` | `(event: Event) => boolean` | - | Optional filter function to control which events are broadcast |
350+
351+
##### Filtering Events with shouldBroadcast
352+
353+
The `shouldBroadcast` option allows you to filter which events trigger activity messages. This is useful in the shell application to exclude events that occur on iframes, since user activity inside embedded modules is already tracked via the communication protocol.
354+
355+
```typescript
356+
// In the shell application
357+
inject(ActivityProducerService).start({
358+
throttleMs: 1000,
359+
shouldBroadcast: (event: Event) => {
360+
// Exclude events on iframes - activity from embedded modules comes via the communication protocol
361+
return !(event.target instanceof HTMLIFrameElement);
362+
}
363+
});
364+
```
365+
366+
##### Tracking Nested Iframes
367+
368+
When a user interacts with content inside an iframe (e.g., a third-party widget, payment form, or embedded content), the parent application cannot detect those interactions directly. This is because:
369+
370+
1. **Cross-origin restrictions**: DOM events inside cross-origin iframes do not bubble up to the parent document.
371+
2. **Focus isolation**: When an iframe has focus, the parent document stops receiving keyboard and mouse events.
372+
373+
This creates a problem for detection of user interactions: a user could be actively filling out a form inside an iframe, but the host application would see no activity and might incorrectly trigger a session timeout.
374+
375+
**Solution**: When `trackNestedIframes` is enabled, the `ActivityProducerService` polls `document.activeElement` to detect when an iframe gains focus. While an iframe has focus, the service simulates activity by periodically emitting `iframeinteraction` events. This ensures the host application knows the user is still active, even though it cannot see the actual interactions inside the iframe.
376+
377+
Enable nested iframe tracking in embedded applications that contain other iframes:
378+
379+
```typescript
380+
inject(ActivityProducerService).start({
381+
throttleMs: 5000,
382+
trackNestedIframes: true,
383+
nestedIframePollIntervalMs: 1000, // Check for iframe focus every second
384+
nestedIframeActivityEmitIntervalMs: 30000 // Send activity every 30s while iframe has focus
385+
});
386+
```
387+
388+
**When to use**: Enable this option in embedded modules that contain iframes whose content you cannot modify to include activity tracking (e.g., third-party widgets, payment providers, or external content).
389+
390+
#### Consumer Configuration
391+
392+
Start the `ActivityConsumerService` to receive activity signals from connected peers:
393+
394+
```typescript
395+
import { Component, inject, effect } from '@angular/core';
396+
import { ActivityConsumerService } from '@ama-mfe/ng-utils';
397+
398+
@Component({
399+
selector: 'app-shell',
400+
template: '...'
401+
})
402+
export class ShellComponent {
403+
private readonly activityConsumer = inject(ActivityConsumerService);
404+
405+
constructor() {
406+
// Start listening for activity messages
407+
this.activityConsumer.start();
408+
409+
// React to activity changes
410+
effect(() => {
411+
const activity = this.activityConsumer.latestReceivedActivity();
412+
if (activity) {
413+
console.log(`Activity from ${activity.channelId}: ${activity.eventType} at ${activity.timestamp}`);
414+
// Reset session timeout, update analytics, etc.
415+
}
416+
});
417+
}
418+
419+
ngOnDestroy() {
420+
this.activityConsumer.stop();
421+
}
422+
}
423+
```
424+
425+
#### Local Activity Signal
426+
427+
The `ActivityProducerService` also exposes a `localActivity` signal for detecting activity within the same application:
428+
429+
```typescript
430+
import { Component, inject, effect } from '@angular/core';
431+
import { ActivityProducerService } from '@ama-mfe/ng-utils';
432+
433+
@Component({
434+
selector: 'app-root',
435+
template: '...'
436+
})
437+
export class AppComponent {
438+
private readonly activityProducer = inject(ActivityProducerService);
439+
440+
constructor() {
441+
this.activityProducer.start({ throttleMs: 1000 });
442+
443+
effect(() => {
444+
const activity = this.activityProducer.localActivity();
445+
if (activity) {
446+
// React to local activity (not throttled for local detection)
447+
}
448+
});
449+
}
450+
}
451+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './available-sender';
22
export * from './error-sender';
33
export * from './error/index';
4+
export * from './user-activity';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
USER_ACTIVITY_MESSAGE_TYPE,
3+
type UserActivityMessageV1_0,
4+
} from '@ama-mfe/messages';
5+
import {
6+
isUserActivityMessage,
7+
} from './user-activity';
8+
9+
describe('isUserActivityMessage', () => {
10+
it('should return true for valid UserActivityMessageV1_0', () => {
11+
const message: UserActivityMessageV1_0 = {
12+
type: USER_ACTIVITY_MESSAGE_TYPE,
13+
version: '1.0',
14+
eventType: 'click',
15+
timestamp: Date.now()
16+
};
17+
18+
expect(isUserActivityMessage(message)).toBe(true);
19+
});
20+
21+
it('should return true for all valid event types', () => {
22+
const eventTypes = ['click', 'keydown', 'scroll', 'touchstart', 'focus', 'visibilitychange', 'iframeinteraction'] as const;
23+
24+
eventTypes.forEach((eventType) => {
25+
const message: UserActivityMessageV1_0 = {
26+
type: USER_ACTIVITY_MESSAGE_TYPE,
27+
version: '1.0',
28+
eventType,
29+
timestamp: Date.now()
30+
};
31+
32+
expect(isUserActivityMessage(message)).toBe(true);
33+
});
34+
});
35+
36+
it('should return false for null', () => {
37+
expect(isUserActivityMessage(null)).toBe(false);
38+
});
39+
40+
it('should return false for undefined', () => {
41+
expect(isUserActivityMessage(undefined)).toBe(false);
42+
});
43+
44+
it('should return false for non-object values', () => {
45+
expect(isUserActivityMessage('string')).toBe(false);
46+
expect(isUserActivityMessage(123)).toBe(false);
47+
expect(isUserActivityMessage(true)).toBe(false);
48+
});
49+
50+
it('should return false for object without type property', () => {
51+
const message = {
52+
version: '1.0',
53+
eventType: 'click',
54+
timestamp: Date.now()
55+
};
56+
57+
expect(isUserActivityMessage(message)).toBe(false);
58+
});
59+
60+
it('should return false for object with wrong type', () => {
61+
const message = {
62+
type: 'wrong_type',
63+
version: '1.0',
64+
eventType: 'click',
65+
timestamp: Date.now()
66+
};
67+
68+
expect(isUserActivityMessage(message)).toBe(false);
69+
});
70+
71+
it('should return false for empty object', () => {
72+
expect(isUserActivityMessage({})).toBe(false);
73+
});
74+
75+
it('should return false for array', () => {
76+
expect(isUserActivityMessage([])).toBe(false);
77+
});
78+
79+
it('should return true even if other properties are missing (only checks type)', () => {
80+
// The type guard only checks for the type property
81+
const message = {
82+
type: USER_ACTIVITY_MESSAGE_TYPE
83+
};
84+
85+
expect(isUserActivityMessage(message)).toBe(true);
86+
});
87+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
USER_ACTIVITY_MESSAGE_TYPE,
3+
type UserActivityMessage,
4+
} from '@ama-mfe/messages';
5+
import type {
6+
VersionedMessage,
7+
} from '@amadeus-it-group/microfrontends';
8+
9+
/**
10+
* Type guard to check if a message is a user activity message
11+
* @param message The message to check
12+
*/
13+
export function isUserActivityMessage(message: unknown): message is UserActivityMessage {
14+
return (
15+
typeof message === 'object'
16+
&& message !== null
17+
&& 'type' in message
18+
&& (message as VersionedMessage).type === USER_ACTIVITY_MESSAGE_TYPE
19+
);
20+
}

packages/@ama-mfe/ng-utils/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './messages/index';
66
export * from './navigation/index';
77
export * from './resize/index';
88
export * from './theme/index';
9+
export * from './user-activity/index';
910
export * from './utils';

0 commit comments

Comments
 (0)