From c2e413decf1ff3e0eee5be81ce02f8c3a0dbcece Mon Sep 17 00:00:00 2001 From: Mark Wolff Date: Thu, 27 Jun 2019 11:59:53 -0700 Subject: [PATCH] Feature: add SPA auto route change tracking (#947) * add spa auto route change tracking * use this.config isntead of config * readme: remove newly unneeded documenation * analyics: add pushState undefined tests * types: add properties plugin types * tests: add any type to tests * send pv duration of 0, refresh operation name * analytics: use self ref to this inside callbacks --- README.md | 11 +-- .../Tests/ApplicationInsights.tests.ts | 84 ++++++++++++++++++- .../package.json | 1 + .../src/JavaScriptSDK/ApplicationInsights.ts | 60 +++++++++++-- .../src/Interfaces/IConfig.ts | 7 ++ 5 files changed, 149 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a3a2de09..5f14e18e 100644 --- a/README.md +++ b/README.md @@ -154,16 +154,13 @@ Most configuration fields are named such that they can be defaulted to falsey. A | appId | null | AppId is used for the correlation between AJAX dependencies happening on the client-side with the server-side requests. When Beacon API is enabled, it cannot be used automatically, but can be set manually in the configuration. Default is null | | enableCorsCorrelation | false | If true, the SDK will add two headers ('Request-Id' and 'Request-Context') to all CORS requests tocorrelate outgoing AJAX dependencies with corresponding requests on the server side. Default is false | | namePrefix | undefined | An optional value that will be used as name postfix for localStorage and cookie name. + ## Single Page Applications -By default, this SDK will **not** handle state based route changing that occurs in single page applications unless you use a plugin designed for your frontend framework (Angular, React, Vue, etc). Currently, we support a separate [React plugin](#available-extensions-for-the-sdk) which you can initialize with this SDK and it will accomplish this for you in your React application. Otherwise, you must manually trigger pageviews on each route change. An example of accomplishing this for a React application is located [here](https://github.com/Azure-Samples/appinsights-guestbook/blob/6555933e19d737b2ff4f9f339cc1b928f0c08cdb/client/src/AppContainer.js#L17-L20). Note that you must refresh the current operation's id **as well as** trigger an additional pageview: -**React Router history listener example** -```js -this.unlisten = this.props.history.listen((location, action) => { - ai.properties.context.telemetryTrace.traceID = Util.newId(); - ai.trackPageView({name: window.location.pathname}); -}); +By default, this SDK will **not** handle state based route changing that occurs in single page applications unless you use a plugin designed for your frontend framework (Angular, React, Vue, etc). + +Currently, we support a separate [React plugin](#available-extensions-for-the-sdk) which you can initialize with this SDK. It will also accomplish route change tracking for you, as well as collect [other React specific telemetry](./vNext/extensions/applicationinsights-react-js). ``` ## Examples diff --git a/vNext/extensions/applicationinsights-analytics-js/Tests/ApplicationInsights.tests.ts b/vNext/extensions/applicationinsights-analytics-js/Tests/ApplicationInsights.tests.ts index 917b1236..79d78980 100644 --- a/vNext/extensions/applicationinsights-analytics-js/Tests/ApplicationInsights.tests.ts +++ b/vNext/extensions/applicationinsights-analytics-js/Tests/ApplicationInsights.tests.ts @@ -1,6 +1,6 @@ /// -import { Util, Exception, SeverityLevel, Trace, PageViewPerformance, PageView } from "@microsoft/applicationinsights-common"; +import { Util, Exception, SeverityLevel, Trace, PageViewPerformance, PageView, IConfig } from "@microsoft/applicationinsights-common"; import { ITelemetryItem, AppInsightsCore, IPlugin, IConfiguration @@ -26,6 +26,88 @@ export class ApplicationInsightsTests extends TestClass { } public registerTests() { + this.testCase({ + name: 'enableAutoRouteTracking: event listener is added to the popstate event', + test: () => { + // Setup + var appInsights = new ApplicationInsights(); + var core = new AppInsightsCore(); + var channel = new ChannelPlugin(); + var eventListenerStub = this.sandbox.stub(window, 'addEventListener'); + + // Act + core.initialize({ + instrumentationKey: '', + enableAutoRouteTracking: true + }, [appInsights, channel]); + + // Assert + Assert.ok(eventListenerStub.calledTwice); + Assert.equal(eventListenerStub.args[0][0], "popstate"); + Assert.equal(eventListenerStub.args[1][0], "locationchange"); + } + }); + + this.testCase({ + name: 'enableAutoRouteTracking: route changes trigger a new pageview', + test: () => { + // Setup + var appInsights = new ApplicationInsights(); + var core = new AppInsightsCore(); + var channel = new ChannelPlugin(); + appInsights['_properties'] = { + context: { telemetryTrace: { traceID: 'not set', name: 'name not set' } } + } + const trackPageViewStub = this.sandbox.stub(appInsights, 'trackPageView'); + + // Act + core.initialize({ + instrumentationKey: '', + enableAutoRouteTracking: true + }, [appInsights, channel]); + window.dispatchEvent(new Event('locationchange')); + + // Assert + Assert.ok(trackPageViewStub.calledOnce); + Assert.ok(appInsights['_properties'].context.telemetryTrace.traceID); + Assert.ok(appInsights['_properties'].context.telemetryTrace.name); + Assert.notEqual(appInsights['_properties'].context.telemetryTrace.traceID, 'not set', 'current operation id is updated after route change'); + Assert.notEqual(appInsights['_properties'].context.telemetryTrace.name, 'name not set', 'current operation name is updated after route change'); + } + }); + + this.testCase({ + name: 'enableAutoRouteTracking: (IE9) app does not crash if history.pushState does not exist', + test: () => { + // Setup + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + history.pushState = null; + history.replaceState = null; + var appInsights = new ApplicationInsights(); + var core = new AppInsightsCore(); + var channel = new ChannelPlugin(); + appInsights['_properties'] = { + context: { telemetryTrace: { traceID: 'not set'}} + } + this.sandbox.stub(appInsights, 'trackPageView'); + + // Act + core.initialize({ + instrumentationKey: '', + enableAutoRouteTracking: true + }, [appInsights, channel]); + window.dispatchEvent(new Event('locationchange')); + + // Assert + Assert.ok(true, 'App does not crash when history object is incomplete'); + + // Cleanup + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + } + }); + this.testCase({ name: 'AppInsightsTests: PageVisitTimeManager is constructed when analytics plugin is initialized', test: () => { diff --git a/vNext/extensions/applicationinsights-analytics-js/package.json b/vNext/extensions/applicationinsights-analytics-js/package.json index 3ffcaad7..ff1ccd39 100644 --- a/vNext/extensions/applicationinsights-analytics-js/package.json +++ b/vNext/extensions/applicationinsights-analytics-js/package.json @@ -17,6 +17,7 @@ "test": "grunt aitests" }, "devDependencies": { + "@microsoft/applicationinsights-properties-js": "2.0.1", "typescript": "2.5.3", "rollup-plugin-node-resolve": "^3.4.0", "rollup-plugin-replace": "^2.1.0", diff --git a/vNext/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/ApplicationInsights.ts b/vNext/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/ApplicationInsights.ts index 15d1bbad..bba2812c 100644 --- a/vNext/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/ApplicationInsights.ts +++ b/vNext/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/ApplicationInsights.ts @@ -4,12 +4,13 @@ */ import { - IConfig, Util, PageViewPerformance, IAppInsights, PageView, RemoteDependencyData, Event, IEventTelemetry, + IConfig, Util, PageViewPerformance, IAppInsights, PageView, RemoteDependencyData, Event as EventTelemetry, IEventTelemetry, TelemetryItemCreator, Metric, Exception, SeverityLevel, Trace, IDependencyTelemetry, IExceptionTelemetry, ITraceTelemetry, IMetricTelemetry, IAutoExceptionTelemetry, IPageViewTelemetryInternal, IPageViewTelemetry, IPageViewPerformanceTelemetry, IPageViewPerformanceTelemetryInternal, ConfigurationManager, DateTimeUtils, - IExceptionInternal + IExceptionInternal, + PropertiesPluginIdentifier } from "@microsoft/applicationinsights-common"; import { @@ -22,6 +23,9 @@ import { PageVisitTimeManager } from "./Telemetry/PageVisitTimeManager"; import { PageViewPerformanceManager } from './Telemetry/PageViewPerformanceManager'; import { ITelemetryConfig } from "../JavaScriptSDK.Interfaces/ITelemetryConfig"; +// For types only +import * as properties from "@microsoft/applicationinsights-properties-js"; + "use strict"; const durationProperty: string = "duration"; @@ -39,6 +43,7 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp private _globalconfig: IConfiguration; private _eventTracking: Timing; private _pageTracking: Timing; + private _properties: properties.PropertiesPlugin; protected _nextPlugin: ITelemetryPlugin; protected _logger: IDiagnosticLogger; // Initialized by Core protected _telemetryInitializers: { (envelope: ITelemetryItem): boolean | void; }[]; // Internal telemetry initializers. @@ -75,6 +80,8 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp config.isCookieUseDisabled = Util.stringToBoolOrDefault(config.isCookieUseDisabled); config.isStorageUseDisabled = Util.stringToBoolOrDefault(config.isStorageUseDisabled); config.isBrowserLinkTrackingEnabled = Util.stringToBoolOrDefault(config.isBrowserLinkTrackingEnabled); + config.enableAutoRouteTracking = Util.stringToBoolOrDefault(config.enableAutoRouteTracking); + config.namePrefix = config.namePrefix || ""; return config; } @@ -113,8 +120,8 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp try { let telemetryItem = TelemetryItemCreator.create( event, - Event.dataType, - Event.envelopeType, + EventTelemetry.dataType, + EventTelemetry.envelopeType, this._logger, customProperties ); @@ -223,7 +230,7 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp public trackPageView(pageView?: IPageViewTelemetry, customProperties?: ICustomProperties) { try { const inPv = pageView || {}; - this._pageViewManager.trackPageView(inPv, customProperties); + this._pageViewManager.trackPageView(inPv, {...inPv.properties, ...inPv.measurements, ...customProperties}); if (this.config.autoTrackPageVisitTime) { this._pageVisitTimeManager.trackPreviousPageVisit(inPv.name, inPv.uri); @@ -540,12 +547,12 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp this.sendPageViewInternal(pageViewItem); } + const instance: IAppInsights = this; if (this.config.disableExceptionTracking === false && !this.config.autoExceptionInstrumented) { // We want to enable exception auto collection and it has not been done so yet const onerror = "onerror"; const originalOnError = window[onerror]; - const instance: IAppInsights = this; window.onerror = function (message, url, lineNumber, columnNumber, error) { const handled = originalOnError && originalOnError(message, url, lineNumber, columnNumber, error); if (handled !== true) { // handled could be typeof function @@ -563,6 +570,47 @@ export class ApplicationInsights implements IAppInsights, ITelemetryPlugin, IApp this.config.autoExceptionInstrumented = true; } + /** + * Create a custom "locationchange" event which is triggered each time the history object is changed + */ + if (this.config.enableAutoRouteTracking === true + && typeof history === "object" && typeof history.pushState === "function" && typeof history.replaceState === "function" + && typeof window === "object") { + const _self = this; + // Find the properties plugin + extensions.forEach(extension => { + if (extension.identifier === PropertiesPluginIdentifier) { + this._properties = extension as properties.PropertiesPlugin; + } + }); + + history.pushState = ( f => function pushState() { + var ret = f.apply(this, arguments); + window.dispatchEvent(new Event(_self.config.namePrefix + "pushState")); + window.dispatchEvent(new Event(_self.config.namePrefix + "locationchange")); + return ret; + })(history.pushState); + + history.replaceState = ( f => function replaceState(){ + var ret = f.apply(this, arguments); + window.dispatchEvent(new Event(_self.config.namePrefix + "replaceState")); + window.dispatchEvent(new Event(_self.config.namePrefix + "locationchange")); + return ret; + })(history.replaceState); + + window.addEventListener(_self.config.namePrefix + "popstate",()=>{ + window.dispatchEvent(new Event(_self.config.namePrefix + "locationchange")); + }); + + window.addEventListener(_self.config.namePrefix + "locationchange", () => { + if (_self._properties && _self._properties.context && _self._properties.context.telemetryTrace) { + _self._properties.context.telemetryTrace.traceID = Util.newId(); + _self._properties.context.telemetryTrace.name = window.location.pathname; + } + _self.trackPageView({ properties: { duration: 0 } }); // SPA route change loading durations are undefined, so send 0 + }); + } + this._isInitialized = true; } diff --git a/vNext/shared/AppInsightsCommon/src/Interfaces/IConfig.ts b/vNext/shared/AppInsightsCommon/src/Interfaces/IConfig.ts index 373d0603..f6e548ee 100644 --- a/vNext/shared/AppInsightsCommon/src/Interfaces/IConfig.ts +++ b/vNext/shared/AppInsightsCommon/src/Interfaces/IConfig.ts @@ -94,6 +94,13 @@ export interface IConfig { */ autoTrackPageVisitTime?: boolean; + /** + * @description Automatically track route changes in Single Page Applications (SPA). If true, each route change will send a new Pageview to Application Insights. + * @type {boolean} + * @memberof IConfig + */ + enableAutoRouteTracking?: boolean; + /** * @description If true, Ajax calls are not autocollected. Default is false * @type {boolean}