ext) as PropertiesPlugin;
84 | }
85 | });
86 | if (reactAISettings.history) {
87 | this.addHistoryListener(reactAISettings.history);
88 | const pageViewTelemetry: IPageViewTelemetry = {
89 | uri: reactAISettings.history.location.pathname,
90 | properties: this.context
91 | };
92 | this._trackInitialPageViewInternal(pageViewTelemetry);
93 | }
94 | }
95 |
96 | // internal only, public method for testing
97 | public _trackInitialPageViewInternal(telemetry: IPageViewTelemetry) {
98 | // Record initial page view, since history.listen is not fired for the initial page
99 | // (see: https://github.com/ReactTraining/history/issues/479#issuecomment-307544999 )
100 | this.appInsights.trackPageView(telemetry);
101 | this.debugLog("recording initial page view.", `uri: ${location.pathname}`);
102 | }
103 |
104 | /**
105 | * Set custom context/custom dimensions for Application Insights
106 | *
107 | * @param {{ [key: string]: any }} properties - custom properties to add to all outbound Application Insights telemetry
108 | * @param {boolean} [clearPrevious=false] - if false(default) multiple calls to setContext will append to/overwrite existing custom dimensions, if true the values are reset
109 | * @memberof ReactAI
110 | */
111 | public setContext(properties: { [key: string]: any }, clearPrevious: boolean = false): void {
112 | if (clearPrevious) {
113 | this.contextProps = {};
114 | this.debugLog("context is reset.");
115 | }
116 | properties = properties || {};
117 | for (const key in properties) {
118 | if (properties.hasOwnProperty(key)) {
119 | this.contextProps[key] = properties[key];
120 | }
121 | }
122 | this.debugLog("context is set to:", this.context);
123 | }
124 |
125 | private customDimensionsInitializer(envelope: ITelemetryItem): boolean | void {
126 | envelope.baseData = envelope.baseData || {};
127 | envelope.baseData.properties = envelope.baseData.properties || {};
128 | const properties = envelope.baseData.properties;
129 | const props = this.context;
130 | for (const key in props) {
131 | if (props.hasOwnProperty(key)) {
132 | properties[key] = props[key];
133 | }
134 | }
135 | if (this.nextPlugin != null) {
136 | this.nextPlugin.processTelemetry(envelope);
137 | }
138 | }
139 |
140 | private addHistoryListener(history: History): void {
141 | history.listen(
142 | (location: Location, action: Action): void => {
143 | // Timeout to ensure any changes to the DOM made by route changes get included in pageView telemetry
144 | setTimeout(() => {
145 | const pageViewTelemetry: IPageViewTelemetry = { uri: location.pathname, properties: this.context };
146 | this.appInsights.trackPageView(pageViewTelemetry);
147 | this.debugLog("recording page view.", `uri: ${location.pathname} action: ${action}`);
148 | }, 500);
149 | }
150 | );
151 | }
152 |
153 | private debugLog(message: string, payload?: any): void {
154 | if (this.isDebugMode) {
155 | console.log(`ReactAI: ${message}`, payload === undefined ? "" : payload);
156 | }
157 | }
158 | }
159 |
160 | export const reactAI = new ReactAI();
161 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Copyright (c) Microsoft Corporation. All rights reserved.
2 | // Licensed under the MIT License.
3 |
4 | import IReactAISettings from "./IReactAISettings";
5 | import { reactAI } from "./ReactAI";
6 | import withAITracking from "./withAITracking";
7 |
8 | export { reactAI, IReactAISettings, withAITracking };
9 |
10 |
--------------------------------------------------------------------------------
/src/withAITracking.tsx:
--------------------------------------------------------------------------------
1 | // Copyright (c) Microsoft Corporation. All rights reserved.
2 | // Licensed under the MIT License.
3 |
4 | import { IMetricTelemetry } from "@microsoft/applicationinsights-web";
5 | import * as React from "react";
6 | import { reactAI } from "./ReactAI";
7 |
8 | /**
9 | * Higher-order component function to hook Application Insights tracking
10 | * in a React component's lifecycle.
11 | *
12 | * @param Component the component to be instrumented with Application Insights tracking
13 | * @param componentName (optional) component name
14 | */
15 | export default function withAITracking(Component: React.ComponentType
, componentName?: string): React.ComponentClass
{
16 |
17 | if (componentName === undefined || componentName === null || typeof componentName !== 'string') {
18 | componentName = Component.prototype.constructor.name;
19 | }
20 |
21 | return class extends React.Component
{
22 | private mountTimestamp: number = 0;
23 | private firstActiveTimestamp: number = 0;
24 | private idleStartTimestamp: number = 0;
25 | private lastActiveTimestamp: number = 0;
26 | private totalIdleTime: number = 0;
27 | private idleCount: number = 0;
28 | private idleTimeout: number = 5000;
29 | private intervalId?: NodeJS.Timeout;
30 |
31 | public componentDidMount() {
32 | this.mountTimestamp = Date.now();
33 | this.firstActiveTimestamp = 0;
34 | this.totalIdleTime = 0;
35 | this.lastActiveTimestamp = 0;
36 | this.idleStartTimestamp = 0;
37 | this.idleCount = 0;
38 |
39 | this.intervalId = setInterval(() => {
40 | if (this.lastActiveTimestamp > 0 && this.idleStartTimestamp === 0 && Date.now() - this.lastActiveTimestamp >= this.idleTimeout) {
41 | this.idleStartTimestamp = Date.now();
42 | this.idleCount++;
43 | this.debugLog("componentDidMount", "Starting idle time.");
44 | }
45 | }, 100);
46 | }
47 |
48 | public componentWillUnmount() {
49 | if (this.mountTimestamp === 0) {
50 | throw new Error("withAITracking:componentWillUnmount: mountTimestamp isn't initialized.");
51 | }
52 |
53 | if (!reactAI.appInsights) {
54 | throw new Error("withAITracking:componentWillUnmount: ReactAI isn't initialized.");
55 | }
56 |
57 | if (this.intervalId) {
58 | clearInterval(this.intervalId);
59 | }
60 |
61 | if (this.firstActiveTimestamp === 0) {
62 | this.debugLog("componentWillUnmount", "Nothing to track.");
63 | return;
64 | }
65 |
66 | const engagementTime = this.getEngagementTimeSeconds();
67 | const metricData: IMetricTelemetry = {
68 | average: engagementTime,
69 | name: "React Component Engaged Time (seconds)",
70 | sampleCount: 1
71 | };
72 |
73 | const additionalProperties: { [key: string]: any } = { "Component Name": componentName };
74 | this.debugLog(
75 | "componentWillUnmount",
76 | `Tracking ${engagementTime} seconds of engagement time for ${componentName}.`
77 | );
78 | reactAI.appInsights.trackMetric(metricData, additionalProperties);
79 | }
80 |
81 | public render() {
82 | return (
83 |
91 |
92 |
93 | );
94 | }
95 |
96 | private trackActivity = (e: React.SyntheticEvent): void => {
97 | if (this.firstActiveTimestamp === 0) {
98 | this.firstActiveTimestamp = Date.now();
99 | this.lastActiveTimestamp = this.firstActiveTimestamp;
100 | } else {
101 | this.lastActiveTimestamp = Date.now();
102 | }
103 |
104 | if (this.idleStartTimestamp > 0) {
105 | const lastIdleTime = this.lastActiveTimestamp - this.idleStartTimestamp;
106 | this.totalIdleTime += lastIdleTime;
107 | this.debugLog("trackActivity", `Idle to active added ${lastIdleTime / 1000} seconds of idle time.`);
108 | this.idleStartTimestamp = 0;
109 | }
110 | }
111 |
112 | private debugLog(from: string, message: string): void {
113 | if (reactAI.isDebugMode) {
114 | console.log(`withAITracking:${componentName}:${from}: ${message}`, {
115 | engagementTime: this.getEngagementTimeSeconds(),
116 | firstActiveTime: this.firstActiveTimestamp,
117 | idleStartTime: this.idleStartTimestamp,
118 | idleTimeMs: this.totalIdleTime,
119 | lastActiveTime: this.lastActiveTimestamp,
120 | mountTimestamp: this.mountTimestamp
121 | });
122 | }
123 | }
124 |
125 | private getEngagementTimeSeconds(): number {
126 | return (Date.now() - this.firstActiveTimestamp - this.totalIdleTime - this.idleCount * this.idleTimeout) / 1000;
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/test/ReactAI.test.ts:
--------------------------------------------------------------------------------
1 | // Copyright (c) Microsoft Corporation. All rights reserved.
2 | // Licensed under the MIT License.
3 |
4 | import { ApplicationInsights } from '@microsoft/applicationinsights-web';
5 | import createHistory from "history/createBrowserHistory";
6 | import { IReactAISettings, reactAI } from '../src';
7 |
8 | const IKEY: string = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx";
9 |
10 | let appInsights: ApplicationInsights;
11 |
12 | describe("ReactAI", () => {
13 |
14 | function init(reactAIconfig: IReactAISettings) {
15 | reactAI._trackInitialPageViewInternal = jest.fn();
16 | appInsights = new ApplicationInsights({
17 | config: {
18 | extensionConfig: {
19 | [reactAI.extensionId]: reactAIconfig
20 | },
21 | extensions: [reactAI],
22 | instrumentationKey: IKEY
23 | }
24 | });
25 | appInsights.loadAppInsights();
26 | }
27 |
28 | it("initializes correctly", () => {
29 | init({});
30 | expect(reactAI).not.toBe(undefined);
31 | expect(appInsights).not.toBe(undefined);
32 | expect(reactAI.isDebugMode).toBe(false);
33 | });
34 |
35 | it("sets debug mode as expected", () => {
36 | init({ debug: true });
37 | expect(reactAI.isDebugMode).toBe(true);
38 | });
39 |
40 | it("sets context correctly", () => {
41 | init({});
42 | reactAI.setContext({ prop1: "value1", prop2: "value2" });
43 | expect(reactAI.context.prop1).toBe("value1");
44 | expect(reactAI.context.prop2).toBe("value2");
45 | });
46 |
47 | it("resets context correctly", () => {
48 | init({});
49 | reactAI.setContext({ prop1: "value1" });
50 | expect(reactAI.context.prop1).toBe("value1");
51 |
52 | reactAI.setContext({ prop2: "value2" }, true);
53 | expect(reactAI.context.prop2).toBe("value2");
54 | expect(reactAI.context.prop1).toBe(undefined);
55 | });
56 |
57 | it("resets context on initialization", () => {
58 | init({ initialContext: { prop1: "value1" } });
59 | expect(reactAI.context.prop1).toBe("value1");
60 | expect(reactAI.context.prop2).toBe(undefined);
61 | });
62 |
63 | it("tracks page views", () => {
64 | const emulatedHistory = createHistory();
65 | const initialContext = { prop1: "value1" };
66 | jest.useFakeTimers();
67 | init({ debug: false, initialContext, history: emulatedHistory });
68 |
69 | // Mock the internal instance of AppInsights
70 | reactAI.appInsights.trackPageView = jest.fn();
71 | reactAI.appInsights.addTelemetryInitializer = jest.fn();
72 |
73 | const pageViewTelemetry1 = { uri: "/", properties: initialContext };
74 | expect(reactAI._trackInitialPageViewInternal).toHaveBeenCalledTimes(1);
75 | expect(reactAI._trackInitialPageViewInternal).toHaveBeenNthCalledWith(1, pageViewTelemetry1);
76 |
77 | // Emulate navigation to different URL-addressed pages
78 | emulatedHistory.push("/home", { some: "state" });
79 | emulatedHistory.push("/new-fancy-page");
80 | jest.runOnlyPendingTimers();
81 |
82 | const pageViewTelemetry2 = { uri: "/home", properties: initialContext };
83 | const pageViewTelemetry3 = { uri: "/new-fancy-page", properties: initialContext };
84 | expect(reactAI.appInsights.trackPageView).toHaveBeenCalledTimes(2);
85 | expect(reactAI.appInsights.trackPageView).toHaveBeenNthCalledWith(1, pageViewTelemetry2);
86 | expect(reactAI.appInsights.trackPageView).toHaveBeenNthCalledWith(2, pageViewTelemetry3);
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/test/TestComponent.tsx:
--------------------------------------------------------------------------------
1 | // Copyright (c) Microsoft Corporation. All rights reserved.
2 | // Licensed under the MIT License.
3 |
4 | import * as React from "react";
5 |
6 | export interface ITestComponentProps {
7 | prop1?: number;
8 | prop2?: string;
9 | }
10 |
11 | export class TestComponent extends React.Component {
12 | public render() {
13 | const { prop1, prop2 } = this.props;
14 | return (
15 |
16 | prop1: {prop1}, prop2: {prop2}
17 |
18 | );
19 | }
20 | }
21 |
22 | export default TestComponent;
23 |
--------------------------------------------------------------------------------
/test/withAITracking.test.tsx:
--------------------------------------------------------------------------------
1 | // Copyright (c) Microsoft Corporation. All rights reserved.
2 | // Licensed under the MIT License.
3 |
4 | import { ApplicationInsights, IMetricTelemetry, IPageViewTelemetry } from "@microsoft/applicationinsights-web";
5 | import * as Enzyme from "enzyme";
6 | import * as Adapter from "enzyme-adapter-react-16";
7 | import * as React from "react";
8 | import { reactAI, withAITracking } from "../src";
9 | import { TestComponent } from "./TestComponent";
10 |
11 | const IKEY: string = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx";
12 | Enzyme.configure({ adapter: new Adapter.default() });
13 |
14 | let trackMetricSpy: jest.SpyInstance;
15 | let appInsights: ApplicationInsights;
16 |
17 | describe("withAITracking(TestComponent)", () => {
18 | const TestComponentWithTracking = withAITracking(TestComponent);
19 | const trackedTestComponentWrapper = () => Enzyme.shallow();
20 |
21 | beforeEach(() => {
22 | appInsights = new ApplicationInsights({
23 | config: {
24 | extensionConfig: {
25 | [reactAI.extensionId]: { debug: false }
26 | },
27 | extensions: [reactAI],
28 | instrumentationKey: IKEY
29 | }
30 | });
31 | appInsights.loadAppInsights();
32 | trackMetricSpy = jest.spyOn(reactAI.appInsights, "trackMetric");
33 | });
34 |
35 | it("should wrap ", () => {
36 | const component = trackedTestComponentWrapper();
37 | expect(component.find(TestComponent).length).toBe(1);
38 | });
39 |
40 | it("shouldn't call trackMetric if there's no user interaction", () => {
41 | const component = trackedTestComponentWrapper();
42 | component.unmount();
43 | expect(trackMetricSpy).toHaveBeenCalledTimes(0);
44 | });
45 |
46 | it("should call trackMetric if there is user interaction", () => {
47 | const component = trackedTestComponentWrapper();
48 | component.simulate("keydown");
49 | component.unmount();
50 |
51 | expect(trackMetricSpy).toHaveBeenCalledTimes(1);
52 | const metricTelemetry: IMetricTelemetry & IPageViewTelemetry = {
53 | average: expect.any(Number),
54 | name: "React Component Engaged Time (seconds)",
55 | properties: expect.any(Object),
56 | sampleCount: 1
57 | };
58 | expect(trackMetricSpy).toHaveBeenCalledWith(metricTelemetry, { "Component Name": "TestComponent" });
59 | });
60 |
61 | it("should use the passed component name in trackMetric", () => {
62 | const TestComponentWithTrackingCustomName = withAITracking(TestComponent, "MyCustomName");
63 | const component = Enzyme.shallow();
64 | component.simulate("mousemove");
65 | component.unmount();
66 |
67 | expect(trackMetricSpy).toHaveBeenCalledTimes(1);
68 | const metricTelemetry: IMetricTelemetry & IPageViewTelemetry = {
69 | average: expect.any(Number),
70 | name: "React Component Engaged Time (seconds)",
71 | properties: expect.any(Object),
72 | sampleCount: 1
73 | };
74 | expect(trackMetricSpy).toHaveBeenCalledWith(metricTelemetry, { "Component Name": "MyCustomName" });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
5 | "module": "es6" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
6 | // "lib": [], /* Specify library files to be included in the compilation. */
7 | // "allowJs": true, /* Allow javascript files to be compiled. */
8 | // "checkJs": true, /* Report errors in .js files. */
9 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
10 | "declaration": true /* Generates corresponding '.d.ts' file. */,
11 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
12 | "sourceMap": true /* Generates corresponding '.map' file. */,
13 | // "outFile": "./", /* Concatenate and emit output to single file. */
14 | "outDir": "./dist-esm" /* Redirect output structure to the directory. */,
15 | // "rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
16 | // "composite": true, /* Enable project compilation */
17 | // "removeComments": true, /* Do not emit comments to output. */
18 | // "noEmit": true, /* Do not emit outputs. */
19 | "importHelpers": true /* Import emit helpers from 'tslib'. */,
20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
22 |
23 | /* Strict Type-Checking Options */
24 | "strict": true /* Enable all strict type-checking options. */,
25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
26 | // "strictNullChecks": true, /* Enable strict null checks. */
27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
31 |
32 | /* Additional Checks */
33 | // "noUnusedLocals": true, /* Report errors on unused locals. */
34 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
35 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
37 |
38 | /* Module Resolution Options */
39 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
42 | // "rootDirs": [] /* List of root folders whose combined content represents the structure of the project at runtime. */,
43 | "typeRoots": ["./node_modules/@types"] /* List of folders to include type definitions from. */,
44 | //"types": [], /* Type declaration files to be included in compilation. */
45 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
48 |
49 | /* Source Map Options */
50 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
51 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
54 |
55 | /* Experimental Options */
56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
58 | "forceConsistentCasingInFileNames": true
59 | },
60 | "include": ["src", "test"],
61 | "exclude": ["legacy", "node_modules", "types"]
62 | }
63 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "defaultSeverity": "warning",
4 | "rules": {
5 | "no-console": false,
6 | "object-literal-sort-keys": false
7 | }
8 | }
9 |
--------------------------------------------------------------------------------