@xh/hoist 73.0.0-SNAPSHOT.1744046548420 → 73.0.0-SNAPSHOT.1744112888481

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/CHANGELOG.md CHANGED
@@ -4,8 +4,27 @@
4
4
 
5
5
  ### 🎁 New Features
6
6
 
7
+ * Added support for posting a "Client Health Report" track message on a configurable interval. This
8
+ message will include basic client information, and can be extended to include any other desired
9
+ data via `XH.trackService.addClientHealthReportSource()`. Enable by updating your app's
10
+ `xhActivityTrackingConfig` to include `clientHealthReport: {intervalMins: XXXX}`.
11
+ * Enabled opt-in support for telemetry in `MsalClient`, leveraging hooks built-in to MSAL to collect
12
+ timing and success/failure count for all events emitted by the library.
7
13
  * Added the reported client app version as a column in the Admin Console WebSockets tab.
8
14
 
15
+ ### 🐞 Bug Fixes
16
+
17
+ * Improved fetch request tracking to include time spent loading headers as specified by application.
18
+
19
+ ### ⚙️ Technical
20
+
21
+ * Update shape of returned `BrowserUtils.getClientDeviceInfo()` to nest several properties under new
22
+ top-level `window` key and report JS heap size / usage values under the `memory` block in MB.
23
+
24
+ ### 📚 Libraries
25
+
26
+ * @azure/msal-browser `3.28 → 4.8.0`
27
+
9
28
  ## v72.2.0 - 2025-03-13
10
29
 
11
30
  ### 🎁 New Features
@@ -24,8 +24,13 @@ export class AppStateModel extends HoistModel {
24
24
  suspendData: AppSuspendData;
25
25
  accessDeniedMessage: string = 'Access Denied';
26
26
 
27
+ /**
28
+ * Timestamp when the app first started loading, prior to even JS download/eval.
29
+ * Read from timestamp set on window within index.html.
30
+ */
31
+ readonly loadStarted: number = window['_xhLoadTimestamp'];
32
+
27
33
  private timings: Record<AppState, number> = {} as Record<AppState, number>;
28
- private loadStarted: number = window['_xhLoadTimestamp']; // set in index.html
29
34
  private lastStateChangeTime: number = this.loadStarted;
30
35
 
31
36
  constructor() {
@@ -26,7 +26,10 @@ export declare class ActivityTrackingModel extends HoistModel {
26
26
  */
27
27
  get queryDisplayString(): string;
28
28
  get endDay(): LocalDate;
29
- get maxRowOptions(): any;
29
+ get maxRowOptions(): {
30
+ value: number;
31
+ label: string;
32
+ }[];
30
33
  get maxRows(): number;
31
34
  /** True if data loaded from the server has been topped by maxRows. */
32
35
  get maxRowsReached(): boolean;
@@ -10,8 +10,12 @@ export declare class AppStateModel extends HoistModel {
10
10
  lastActivityMs: number;
11
11
  suspendData: AppSuspendData;
12
12
  accessDeniedMessage: string;
13
+ /**
14
+ * Timestamp when the app first started loading, prior to even JS download/eval.
15
+ * Read from timestamp set on window within index.html.
16
+ */
17
+ readonly loadStarted: number;
13
18
  private timings;
14
- private loadStarted;
15
19
  private lastStateChangeTime;
16
20
  constructor();
17
21
  setAppState(nextState: AppState): void;
@@ -10,6 +10,6 @@ import '@xh/hoist/desktop/register';
10
10
  *
11
11
  * Overflowing tabs can be displayed in a dropdown menu if `enableOverflow` is true.
12
12
  * Note that in order for tabs to overflow, the TabSwitcher or it's wrapper must have a
13
- * a maximum width.
13
+ * maximum width.
14
14
  */
15
15
  export declare const TabSwitcher: import("react").FC<TabSwitcherProps>, tabSwitcher: import("@xh/hoist/core").ElementFactory<TabSwitcherProps>;
@@ -1,5 +1,4 @@
1
1
  import { Token } from './Token';
2
- export type TokenMap = Record<string, Token>;
3
2
  export interface AccessTokenSpec {
4
3
  /**
5
4
  * Mode governing when the access token should be requested from provider:
@@ -12,3 +11,29 @@ export interface AccessTokenSpec {
12
11
  /** Scopes for the desired access token.*/
13
12
  scopes: string[];
14
13
  }
14
+ export type TokenMap = Record<string, Token>;
15
+ /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
16
+ export interface TelemetryResults {
17
+ /** Stats by event type - */
18
+ events: Record<string, TelemetryEventResults>;
19
+ }
20
+ /** Aggregated telemetry results for a single type of event. */
21
+ export interface TelemetryEventResults {
22
+ firstTime: Date;
23
+ lastTime: Date;
24
+ successCount: number;
25
+ failureCount: number;
26
+ /** Timing info (in ms) for event instances reported with duration. */
27
+ duration: {
28
+ count: number;
29
+ total: number;
30
+ average: number;
31
+ worst: number;
32
+ };
33
+ lastFailure?: {
34
+ time: Date;
35
+ duration: number;
36
+ code: string;
37
+ name: string;
38
+ };
39
+ }
@@ -1,8 +1,8 @@
1
1
  import * as msal from '@azure/msal-browser';
2
2
  import { LogLevel } from '@azure/msal-browser';
3
3
  import { Token } from '@xh/hoist/security/Token';
4
- import { AccessTokenSpec, TokenMap } from '../Types';
5
4
  import { BaseOAuthClient, BaseOAuthClientConfig } from '../BaseOAuthClient';
5
+ import { AccessTokenSpec, TelemetryResults, TokenMap } from '../Types';
6
6
  export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
7
7
  /**
8
8
  * Authority for your organization's tenant: `https://login.microsoftonline.com/[tenantId]`.
@@ -16,6 +16,12 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
16
16
  * The value of the domain hint is a registered domain for the tenant.
17
17
  */
18
18
  domainHint?: string;
19
+ /**
20
+ * True to enable support for built-in telemetry provided by this class's internal MSAL client.
21
+ * Captured performance events will be summarized via {@link telemetryResults}.
22
+ * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
23
+ */
24
+ enableTelemetry?: boolean;
19
25
  /**
20
26
  * If specified, the client will use this value when initializing the app to enforce a minimum
21
27
  * amount of time during which no further auth flow with the provider should be necessary.
@@ -80,6 +86,9 @@ export declare class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTo
80
86
  private client;
81
87
  private account;
82
88
  private initialTokenLoad;
89
+ /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
90
+ telemetryResults: TelemetryResults;
91
+ private _telemetryCbHandle;
83
92
  constructor(config: MsalClientConfig);
84
93
  protected doInitAsync(): Promise<TokenMap>;
85
94
  protected doLoginPopupAsync(): Promise<void>;
@@ -87,6 +96,8 @@ export declare class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTo
87
96
  protected fetchIdTokenAsync(useCache?: boolean): Promise<Token>;
88
97
  protected fetchAccessTokenAsync(spec: MsalTokenSpec, useCache?: boolean): Promise<Token>;
89
98
  protected doLogoutAsync(): Promise<void>;
99
+ enableTelemetry(): void;
100
+ disableTelemetry(): void;
90
101
  private loginSsoAsync;
91
102
  private createClientAsync;
92
103
  private logFromMsal;
@@ -6,15 +6,45 @@ import { HoistService, TrackOptions } from '@xh/hoist/core';
6
6
  */
7
7
  export declare class TrackService extends HoistService {
8
8
  static instance: TrackService;
9
+ private clientHealthReportSources;
9
10
  private oncePerSessionSent;
10
11
  private pending;
11
12
  initAsync(): Promise<void>;
12
- get conf(): any;
13
+ get conf(): ActivityTrackingConfig;
13
14
  get enabled(): boolean;
14
15
  /** Track User Activity. */
15
16
  track(options: TrackOptions | string): void;
17
+ /**
18
+ * Register a new source for client health report data. No-op if background health report is
19
+ * not generally enabled via `xhActivityTrackingConfig.clientHealthReport.intervalMins`.
20
+ *
21
+ * @param key - key under which to report the data - can be used to remove this source later.
22
+ * @param callback - function returning serializable to include with each report.
23
+ */
24
+ addClientHealthReportSource(key: string, callback: () => any): void;
25
+ /** Unregister a previously-enabled source for client health report data. */
26
+ removeClientHealthReportSource(key: string): void;
16
27
  private pushPendingAsync;
17
28
  private pushPendingBuffered;
18
29
  private toServerJson;
19
30
  private logMessage;
31
+ private sendClientHealthReport;
20
32
  }
33
+ interface ActivityTrackingConfig {
34
+ clientHealthReport?: Partial<TrackOptions> & {
35
+ intervalMins: number;
36
+ };
37
+ enabled: boolean;
38
+ logData: boolean;
39
+ maxDataLength: number;
40
+ maxRows?: {
41
+ default: number;
42
+ options: number[];
43
+ };
44
+ levels?: Array<{
45
+ username: string | '*';
46
+ category: string | '*';
47
+ severity: 'DEBUG' | 'INFO' | 'WARN';
48
+ }>;
49
+ }
50
+ export {};
@@ -1,4 +1,41 @@
1
1
  /**
2
2
  * Extract information (if available) about the client browser's window, screen, and network speed.
3
3
  */
4
- export declare function getClientDeviceInfo(): any;
4
+ export declare function getClientDeviceInfo(): ClientDeviceInfo;
5
+ export interface ClientDeviceInfo {
6
+ window: {
7
+ devicePixelRatio: number;
8
+ screenX: number;
9
+ screenY: number;
10
+ innerWidth: number;
11
+ innerHeight: number;
12
+ outerWidth: number;
13
+ outerHeight: number;
14
+ };
15
+ screen?: {
16
+ availWidth: number;
17
+ availHeight: number;
18
+ width: number;
19
+ height: number;
20
+ colorDepth: number;
21
+ pixelDepth: number;
22
+ availLeft: number;
23
+ availTop: number;
24
+ orientation?: {
25
+ angle: number;
26
+ type: string;
27
+ };
28
+ };
29
+ connection?: {
30
+ downlink: number;
31
+ effectiveType: string;
32
+ rtt: number;
33
+ };
34
+ memory: {
35
+ modelCount: number;
36
+ usedPctLimit?: number;
37
+ jsHeapSizeLimit?: number;
38
+ totalJSHeapSize?: number;
39
+ usedJSHeapSize?: number;
40
+ };
41
+ }
@@ -41,7 +41,7 @@ import {CSSProperties, ReactElement, KeyboardEvent} from 'react';
41
41
  *
42
42
  * Overflowing tabs can be displayed in a dropdown menu if `enableOverflow` is true.
43
43
  * Note that in order for tabs to overflow, the TabSwitcher or it's wrapper must have a
44
- * a maximum width.
44
+ * maximum width.
45
45
  */
46
46
  export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>({
47
47
  displayName: 'TabSwitcher',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "73.0.0-SNAPSHOT.1744046548420",
3
+ "version": "73.0.0-SNAPSHOT.1744112888481",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@auth0/auth0-spa-js": "~2.1.3",
33
- "@azure/msal-browser": "~3.28.0",
33
+ "@azure/msal-browser": "~4.8.0",
34
34
  "@blueprintjs/core": "^5.10.5",
35
35
  "@blueprintjs/datetime": "^5.3.7",
36
36
  "@blueprintjs/datetime2": "^2.3.7",
@@ -119,7 +119,7 @@ export abstract class BaseOAuthClient<
119
119
  throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
120
120
 
121
121
  this.idScopes = union(['openid', 'email'], config.idScopes);
122
- this.accessSpecs = this.config.accessTokens;
122
+ this.accessSpecs = this.config.accessTokens ?? {};
123
123
  }
124
124
 
125
125
  /**
@@ -163,7 +163,10 @@ export abstract class BaseOAuthClient<
163
163
  * Get an Access token.
164
164
  */
165
165
  async getAccessTokenAsync(key: string): Promise<Token> {
166
- return this.fetchAccessTokenAsync(this.accessSpecs[key], true);
166
+ const spec = this.accessSpecs[key];
167
+ if (!spec) throw XH.exception(`No access token spec configured for key "${key}"`);
168
+
169
+ return this.fetchAccessTokenAsync(spec, true);
167
170
  }
168
171
 
169
172
  /**
package/security/Types.ts CHANGED
@@ -7,8 +7,6 @@
7
7
 
8
8
  import {Token} from './Token';
9
9
 
10
- export type TokenMap = Record<string, Token>;
11
-
12
10
  export interface AccessTokenSpec {
13
11
  /**
14
12
  * Mode governing when the access token should be requested from provider:
@@ -22,3 +20,32 @@ export interface AccessTokenSpec {
22
20
  /** Scopes for the desired access token.*/
23
21
  scopes: string[];
24
22
  }
23
+
24
+ export type TokenMap = Record<string, Token>;
25
+
26
+ /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
27
+ export interface TelemetryResults {
28
+ /** Stats by event type - */
29
+ events: Record<string, TelemetryEventResults>;
30
+ }
31
+
32
+ /** Aggregated telemetry results for a single type of event. */
33
+ export interface TelemetryEventResults {
34
+ firstTime: Date;
35
+ lastTime: Date;
36
+ successCount: number;
37
+ failureCount: number;
38
+ /** Timing info (in ms) for event instances reported with duration. */
39
+ duration: {
40
+ count: number;
41
+ total: number;
42
+ average: number;
43
+ worst: number;
44
+ };
45
+ lastFailure?: {
46
+ time: Date;
47
+ duration: number;
48
+ code: string;
49
+ name: string;
50
+ };
51
+ }
@@ -7,6 +7,8 @@
7
7
  import * as msal from '@azure/msal-browser';
8
8
  import {
9
9
  AccountInfo,
10
+ BrowserPerformanceClient,
11
+ Configuration,
10
12
  IPublicClientApplication,
11
13
  LogLevel,
12
14
  PopupRequest,
@@ -14,10 +16,10 @@ import {
14
16
  } from '@azure/msal-browser';
15
17
  import {XH} from '@xh/hoist/core';
16
18
  import {Token} from '@xh/hoist/security/Token';
17
- import {AccessTokenSpec, TokenMap} from '../Types';
18
19
  import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
19
20
  import {flatMap, union, uniq} from 'lodash';
20
21
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
22
+ import {AccessTokenSpec, TelemetryResults, TokenMap} from '../Types';
21
23
 
22
24
  export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
23
25
  /**
@@ -34,6 +36,13 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
34
36
  */
35
37
  domainHint?: string;
36
38
 
39
+ /**
40
+ * True to enable support for built-in telemetry provided by this class's internal MSAL client.
41
+ * Captured performance events will be summarized via {@link telemetryResults}.
42
+ * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
43
+ */
44
+ enableTelemetry?: boolean;
45
+
37
46
  /**
38
47
  * If specified, the client will use this value when initializing the app to enforce a minimum
39
48
  * amount of time during which no further auth flow with the provider should be necessary.
@@ -104,6 +113,10 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
104
113
  private account: AccountInfo; // Authenticated account
105
114
  private initialTokenLoad: boolean;
106
115
 
116
+ /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
117
+ telemetryResults: TelemetryResults = {events: {}};
118
+ private _telemetryCbHandle: string = null;
119
+
107
120
  constructor(config: MsalClientConfig) {
108
121
  super({
109
122
  initRefreshTokenExpirationOffsetSecs: -1,
@@ -118,6 +131,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
118
131
  //-------------------------------------------
119
132
  protected override async doInitAsync(): Promise<TokenMap> {
120
133
  const client = (this.client = await this.createClientAsync());
134
+ if (this.config.enableTelemetry) {
135
+ this.enableTelemetry();
136
+ }
121
137
 
122
138
  // 0) Handle redirect return
123
139
  const redirectResp = await client.handleRedirectPromise();
@@ -247,6 +263,86 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
247
263
  : await client.logoutPopup(opts);
248
264
  }
249
265
 
266
+ //------------------------
267
+ // Telemetry
268
+ //------------------------
269
+ enableTelemetry(): void {
270
+ if (this._telemetryCbHandle) {
271
+ this.logInfo('Telemetry already enabled', this.telemetryResults);
272
+ return;
273
+ }
274
+
275
+ this.telemetryResults = {events: {}};
276
+
277
+ this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
278
+ events.forEach(e => {
279
+ try {
280
+ const {events} = this.telemetryResults,
281
+ {name, startTimeMs, durationMs, success, errorName, errorCode} = e,
282
+ eTime = startTimeMs ? new Date(startTimeMs) : new Date();
283
+
284
+ const eResult = (events[name] ??= {
285
+ firstTime: eTime,
286
+ lastTime: eTime,
287
+ successCount: 0,
288
+ failureCount: 0,
289
+ duration: {count: 0, total: 0, average: 0, worst: 0},
290
+ lastFailure: null
291
+ });
292
+ eResult.lastTime = eTime;
293
+
294
+ if (success) {
295
+ eResult.successCount++;
296
+ } else {
297
+ eResult.failureCount++;
298
+ eResult.lastFailure = {
299
+ time: eTime,
300
+ duration: e.durationMs,
301
+ code: errorCode,
302
+ name: errorName
303
+ };
304
+ }
305
+
306
+ if (durationMs) {
307
+ const {duration} = eResult;
308
+ duration.count++;
309
+ duration.total += durationMs;
310
+ duration.average = Math.round(duration.total / duration.count);
311
+ duration.worst = Math.max(duration.worst, durationMs);
312
+ }
313
+ } catch (e) {
314
+ this.logError(`Error processing telemetry event`, e);
315
+ }
316
+ });
317
+ });
318
+
319
+ // Ask TrackService to include in background health check report, if enabled on that service.
320
+ // Handle TrackService not yet initialized (common, this client likely initialized before.)
321
+ this.addReaction({
322
+ when: () => XH.appIsRunning,
323
+ run: () =>
324
+ XH.trackService.addClientHealthReportSource(
325
+ 'msalClient',
326
+ () => this.telemetryResults
327
+ )
328
+ });
329
+
330
+ this.logInfo('Telemetry enabled');
331
+ }
332
+
333
+ disableTelemetry(): void {
334
+ if (!this._telemetryCbHandle) {
335
+ this.logInfo('Telemetry already disabled');
336
+ return;
337
+ }
338
+
339
+ this.client.removePerformanceCallback(this._telemetryCbHandle);
340
+ this._telemetryCbHandle = null;
341
+
342
+ XH.trackService.removeClientHealthReportSource('msalClient');
343
+ this.logInfo('Telemetry disabled', this.telemetryResults);
344
+ }
345
+
250
346
  //------------------------
251
347
  // Private implementation
252
348
  //------------------------
@@ -263,29 +359,38 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
263
359
  }
264
360
 
265
361
  private async createClientAsync(): Promise<IPublicClientApplication> {
266
- const {clientId, authority, msalLogLevel, msalClientOptions} = this.config;
362
+ const {clientId, authority, msalLogLevel, msalClientOptions, enableTelemetry} = this.config;
267
363
  throwIf(!authority, 'Missing MSAL authority. Please review your configuration.');
268
364
 
269
- return msal.PublicClientApplication.createPublicClientApplication(
270
- mergeDeep(
271
- {
272
- auth: {
273
- clientId,
274
- authority,
275
- postLogoutRedirectUri: this.postLogoutRedirectUrl
276
- },
277
- system: {
278
- loggerOptions: {
279
- loggerCallback: this.logFromMsal,
280
- logLevel: msalLogLevel
281
- }
282
- },
283
- cache: {
284
- cacheLocation: 'localStorage' // allows sharing auth info across tabs.
365
+ const mergedConf: Configuration = mergeDeep(
366
+ {
367
+ auth: {
368
+ clientId,
369
+ authority,
370
+ postLogoutRedirectUri: this.postLogoutRedirectUrl
371
+ },
372
+ system: {
373
+ loggerOptions: {
374
+ loggerCallback: this.logFromMsal,
375
+ logLevel: msalLogLevel
285
376
  }
286
377
  },
287
- msalClientOptions
288
- )
378
+ cache: {
379
+ cacheLocation: 'localStorage' // allows sharing auth info across tabs.
380
+ }
381
+ },
382
+ msalClientOptions
383
+ );
384
+
385
+ return msal.PublicClientApplication.createPublicClientApplication(
386
+ enableTelemetry
387
+ ? {
388
+ ...mergedConf,
389
+ telemetry: {
390
+ client: new BrowserPerformanceClient(mergedConf)
391
+ }
392
+ }
393
+ : mergedConf
289
394
  );
290
395
  }
291
396
 
@@ -217,8 +217,9 @@ export class FetchService extends HoistService {
217
217
  //-----------------------
218
218
  private async fetchInternalAsync(opts: FetchOptions): Promise<any> {
219
219
  opts = this.withCorrelationId(opts);
220
- opts = await this.withDefaultHeadersAsync(opts);
221
- let ret = this.managedFetchAsync(opts);
220
+
221
+ // Core Promise - chained with custom headers callback to ensure that work is included in overall tracked time.
222
+ let ret = this.withDefaultHeadersAsync(opts).then(opts => this.managedFetchAsync(opts));
222
223
 
223
224
  // Apply tracking
224
225
  const {correlationId, loadSpec, track} = opts;