diff --git a/extensions/applicationinsights-clickanalytics-js/README.md b/extensions/applicationinsights-clickanalytics-js/README.md index a44f7f3ed..82f6770e9 100644 --- a/extensions/applicationinsights-clickanalytics-js/README.md +++ b/extensions/applicationinsights-clickanalytics-js/README.md @@ -130,6 +130,14 @@ appInsights.loadAppInsights(); | [behaviorValidator](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IClickAnalyticsConfiguration.html#behaviorValidator) | Function | null | Callback function to use for the `data-*-bhvr` value validation. For more information, go to [behaviorValidator section](#behaviorvalidator).| | [defaultRightClickBhvr](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IClickAnalyticsConfiguration.html#defaultRightClickBhvr) | string (or) number | '' | Default Behavior value when Right Click event has occurred. This value will be overridden if the element has the `data-*-bhvr` attribute. | | [dropInvalidEvents](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IClickAnalyticsConfiguration.html#dropInvalidEvents) | boolean | false | Flag to drop events that do not have useful click data. | +| [trackElementTypes](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IClickAnalyticsConfiguration.html#trackElementTypes) | string | "A,BUTTON,AREA,INPUT" | Comma separated list of element tag names that are automatically captured. | +| [trackInputTypes](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IClickAnalyticsConfiguration.html#trackInputTypes) | string | "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT" | Comma separated list of `` `type` values that are automatically captured when `INPUT` is included in `trackElementTypes`. See [Input element capture](#input-element-capture). | + +### Input element capture + +Including `INPUT` in `trackElementTypes` does not capture clicks on every `` element. Only inputs whose `type` is listed in `trackInputTypes` are automatically captured, by default `button`, `checkbox`, `radio`, `reset` and `submit`. Clicks on other input types such as `text`, `email`, `number` or `search` (and inputs without a `type` attribute, which default to `text`) are ignored. + +You can add other input types, for example `trackInputTypes: "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT,TEXT,SEARCH"`. To avoid collecting user entered data, the plugin never uses the `value` of these additional input types as the default content name; it falls back to the `name`, `alt` or `id` attribute instead. Review the data your application tags with `data-*` attributes before enabling additional input types. ### [IValueCallback](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-clickanalytics-js/interfaces/IValueCallback.html) diff --git a/extensions/applicationinsights-clickanalytics-js/Tests/Unit/src/ClickEventTest.ts b/extensions/applicationinsights-clickanalytics-js/Tests/Unit/src/ClickEventTest.ts index 4e5953219..12b2a323a 100644 --- a/extensions/applicationinsights-clickanalytics-js/Tests/Unit/src/ClickEventTest.ts +++ b/extensions/applicationinsights-clickanalytics-js/Tests/Unit/src/ClickEventTest.ts @@ -79,6 +79,7 @@ export class ClickEventTest extends AITestClass { Assert.deepEqual(extConfig.dataTags, dataTagsDefault, "udataTags should be set by default"); Assert.deepEqual(extConfig.coreData, coreDataDefault, "udataTags should be set by default"); Assert.deepEqual(extConfig.trackElementTypes, defaultElementTypes, "trackElementTypes should be set by default"); + Assert.equal(extConfig.trackInputTypes, "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT", "trackInputTypes should be set by default"); Assert.ok(extConfig.callback, "callback should be set by default"); let callbacks = extConfig.callback; @@ -271,6 +272,148 @@ export class ClickEventTest extends AITestClass { } }); + this.testCase({ + name: "autoCapture: input types are filtered by trackInputTypes and support dynamic changes", + useFakeTimers: true, + test: () => { + const config = { + coreData: {}, + autoCapture: true, + dataTags: { + useDefaultContentNameOrId: true + } + } as IClickAnalyticsConfiguration; + let clickAnalyticsPlugin = new ClickAnalyticsPlugin(); + let core = new AppInsightsCore(); + let channel = new ChannelPlugin(); + core.initialize({ + instrumentationKey: "testIkey", + extensionConfig: { + [clickAnalyticsPlugin.identifier]: config + } + } as IConfig & IConfiguration, [clickAnalyticsPlugin, channel]); + + let textInput = document.createElement("input"); + textInput.setAttribute("type", "text"); + textInput.setAttribute("id", "testTextInput"); + textInput.value = "user entered secret"; + document.body.appendChild(textInput); + + let noTypeInput = document.createElement("input"); + noTypeInput.setAttribute("name", "testNoTypeInput"); + noTypeInput.value = "another secret"; + document.body.appendChild(noTypeInput); + + let checkbox = document.createElement("input"); + checkbox.setAttribute("type", "checkbox"); + checkbox.setAttribute("id", "testCheckbox"); + document.body.appendChild(checkbox); + + this.onDone(() => { + core.unload(false); + document.body.removeChild(textInput); + document.body.removeChild(noTypeInput); + document.body.removeChild(checkbox); + }); + + let spy = this.sandbox.spy(clickAnalyticsPlugin.core, "track"); + let click = (element: HTMLElement) => { + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + this.clock.tick(500); + }; + + // Defaults: text and untyped inputs are ignored, checkbox is captured + click(textInput); + click(noTypeInput); + Assert.equal(0, spy.callCount, "text inputs should not be captured by default"); + click(checkbox); + Assert.equal(1, spy.callCount, "checkbox input should be captured by default"); + + // Dynamically opt in to text inputs (case and whitespace insensitive) + core.config.extensionConfig[clickAnalyticsPlugin.identifier].trackInputTypes = " checkbox , Text "; + this.clock.tick(1); + spy.resetHistory(); + + click(textInput); + Assert.equal(1, spy.callCount, "text input should be captured once configured"); + let calledEvent: ITelemetryItem = spy.getCall(0).args[0]; + Assert.equal("testTextInput", calledEvent.baseData["name"], "text input value must not be used as the content name"); + Assert.ok(!strContains(JSON.stringify(calledEvent), "secret"), "text input value must not be captured"); + + click(noTypeInput); + Assert.equal(2, spy.callCount, "input without a type attribute defaults to text and should be captured"); + calledEvent = spy.getCall(1).args[0]; + Assert.equal("testNoTypeInput", calledEvent.baseData["name"], "input name should be used as the content name"); + Assert.ok(!strContains(JSON.stringify(calledEvent), "secret"), "untyped input value must not be captured"); + + // Empty list disables all input capture + core.config.extensionConfig[clickAnalyticsPlugin.identifier].trackInputTypes = ""; + this.clock.tick(1); + spy.resetHistory(); + click(checkbox); + click(textInput); + Assert.equal(0, spy.callCount, "no inputs should be captured with an empty trackInputTypes"); + } + }); + + this.testCase({ + name: "PageAction: XHTML (lowercase tagName) inputs do not expose user entered values", + useFakeTimers: true, + test: () => { + const config = { + dataTags: { + useDefaultContentNameOrId: true + } + }; + const clickAnalyticsPlugin = new ClickAnalyticsPlugin(); + const core = new AppInsightsCore(); + const channel = new ChannelPlugin(); + const traceLogger = new DiagnosticLogger({ loggingLevelConsole: 1 } as any); + core.initialize({ + instrumentationKey: "testIkey", + extensionConfig: { + [clickAnalyticsPlugin.identifier]: config + } + } as IConfig & IConfiguration, [clickAnalyticsPlugin, channel]); + this.onDone(() => { + core.unload(false); + }); + + let extConfig = core.config["extensionConfig"][clickAnalyticsPlugin.identifier]; + const contentHandler = new DomContentHandler(extConfig, traceLogger); + const pageAction = new PageAction(clickAnalyticsPlugin, extConfig, contentHandler, null, {}, traceLogger); + let spy = this.sandbox.spy(clickAnalyticsPlugin.core, "track"); + + const xhtmlNs = "http://www.w3.org/1999/xhtml"; + const xhtmlDoc = document.implementation.createDocument(xhtmlNs, "html", null); + + let textInput = xhtmlDoc.createElementNS(xhtmlNs, "input") as HTMLInputElement; + textInput.setAttribute("type", "text"); + textInput.setAttribute("id", "testXhtmlInput"); + textInput.value = "xhtml secret"; + Assert.equal("input", textInput.tagName, "XHTML elements keep a lowercase tagName"); + + pageAction.capturePageAction(textInput); + this.clock.tick(500); + Assert.equal(1, spy.callCount, "XHTML text input should be captured"); + let calledEvent: ITelemetryItem = spy.getCall(0).args[0]; + Assert.equal("testXhtmlInput", calledEvent.baseData["name"], "XHTML text input value must not be used as the content name"); + Assert.ok(!strContains(JSON.stringify(calledEvent), "secret"), "XHTML text input value must not be captured"); + + let buttonInput = xhtmlDoc.createElementNS(xhtmlNs, "input") as HTMLInputElement; + buttonInput.setAttribute("type", "button"); + buttonInput.setAttribute("id", "testXhtmlButton"); + buttonInput.value = "Save"; + + pageAction.capturePageAction(buttonInput); + this.clock.tick(500); + Assert.equal(2, spy.callCount, "XHTML button input should be captured"); + calledEvent = spy.getCall(1).args[0]; + Assert.ok(strContains(calledEvent.data["content"], "\"contentName\":\"Save\""), + "XHTML button input label should still be used as the content name"); + } + }); + this.testCase({ name: "trackPageView properties are correctly assigned (Empty)", test: () => { @@ -530,7 +673,8 @@ export class ClickEventTest extends AITestClass { dropInvalidEvents : false, urlCollectHash: false, urlCollectQuery: false, - trackElementTypes: "A,BUTTON,AREA,INPUT" + trackElementTypes: "A,BUTTON,AREA,INPUT", + trackInputTypes: "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT" }, core.config.extensionConfig[clickAnalyticsPlugin.identifier]); } diff --git a/extensions/applicationinsights-clickanalytics-js/src/ClickAnalyticsPlugin.ts b/extensions/applicationinsights-clickanalytics-js/src/ClickAnalyticsPlugin.ts index da4cb1b7f..45804ad41 100644 --- a/extensions/applicationinsights-clickanalytics-js/src/ClickAnalyticsPlugin.ts +++ b/extensions/applicationinsights-clickanalytics-js/src/ClickAnalyticsPlugin.ts @@ -53,7 +53,8 @@ const defaultValues: IConfigDefaults = objDeepFree dropInvalidEvents : false, urlCollectHash: false, urlCollectQuery: false, - trackElementTypes: cfgDfString("A,BUTTON,AREA,INPUT") + trackElementTypes: cfgDfString("A,BUTTON,AREA,INPUT"), + trackInputTypes: cfgDfString("BUTTON,CHECKBOX,RADIO,RESET,SUBMIT") }); function _dataPrefixChk(val: any) { @@ -72,7 +73,6 @@ export class ClickAnalyticsPlugin extends BaseTelemetryPlugin { let _pageAction: PageAction; let _autoCaptureHandler: IAutoCaptureHandler; let _contentHandler: IContentHandler; - let _autoCapture: boolean; dynamicProto(ClickAnalyticsPlugin, this, (_self, _base) => { let _identifier = _self.identifier; @@ -154,11 +154,10 @@ export class ClickAnalyticsPlugin extends BaseTelemetryPlugin { _autoCaptureHandler._doUnload(); } _autoCaptureHandler = new AutoCaptureHandler(_self, _config, _pageAction, logger); - let autoCapture = !!_config.autoCapture; - if (!_autoCapture && autoCapture) { + // The previous handler (and its listeners) was unloaded above, so always re-attach when enabled + if (_config.autoCapture) { _autoCaptureHandler.click(); } - _autoCapture = autoCapture; })); } @@ -167,7 +166,6 @@ export class ClickAnalyticsPlugin extends BaseTelemetryPlugin { _pageAction = null; _autoCaptureHandler = null; _contentHandler = null; - _autoCapture = false; } }); } diff --git a/extensions/applicationinsights-clickanalytics-js/src/Interfaces/Datamodel.ts b/extensions/applicationinsights-clickanalytics-js/src/Interfaces/Datamodel.ts index da6a7c938..21aec7b5a 100644 --- a/extensions/applicationinsights-clickanalytics-js/src/Interfaces/Datamodel.ts +++ b/extensions/applicationinsights-clickanalytics-js/src/Interfaces/Datamodel.ts @@ -56,6 +56,18 @@ export interface IClickAnalyticsConfiguration { * If set, it will combine with the default element types. */ trackElementTypes?: string; + + /** + * A comma separated list of `` element `type` values that are automatically captured when `INPUT` is included in + * `trackElementTypes`. Clicks on `` elements with any other type (including `text`, `email`, `number`, `search` + * and inputs without a `type` attribute, which default to `text`) are not automatically captured. + * Default is "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT". + * + * To avoid collecting user entered data, the `value` of inputs other than `button`, `checkbox`, `radio`, `reset` and `submit` + * is never used as the default content name. + * @example "BUTTON,CHECKBOX,RADIO,RESET,SUBMIT,TEXT,SEARCH" + */ + trackInputTypes?: string; } /** diff --git a/extensions/applicationinsights-clickanalytics-js/src/handlers/AutoCaptureHandler.ts b/extensions/applicationinsights-clickanalytics-js/src/handlers/AutoCaptureHandler.ts index 0fd543aca..066be2693 100644 --- a/extensions/applicationinsights-clickanalytics-js/src/handlers/AutoCaptureHandler.ts +++ b/extensions/applicationinsights-clickanalytics-js/src/handlers/AutoCaptureHandler.ts @@ -14,7 +14,9 @@ import { IAutoCaptureHandler, IClickAnalyticsConfiguration, IPageActionOverrideV import { isElementDnt, isKeyboardEnter, isKeyboardSpace, isLeftClick, isMiddleClick, isRightClick } from "../common/Utils"; import { PageAction } from "../events/PageAction"; -const clickCaptureInputTypes = { BUTTON: true, CHECKBOX: true, RADIO: true, RESET: true, SUBMIT: true }; +function _parseTypes(value: string): string[] { + return arrMap((value || "").toUpperCase().split(","), tag => strTrim(tag)); +} export class AutoCaptureHandler implements IAutoCaptureHandler { @@ -26,8 +28,10 @@ export class AutoCaptureHandler implements IAutoCaptureHandler { protected _traceLogger: IDiagnosticLogger) { let _evtNamespace = mergeEvtNamespace(createUniqueNamespace("AutoCaptureHandler"), (_analyticsPlugin as any)._evtNamespace); let _clickCaptureElements: string[]; + let _clickCaptureInputTypes: string[]; let unloadHandler: IUnloadHook = onConfigChange(_config, () => { - _clickCaptureElements = arrMap(_config.trackElementTypes.toUpperCase().split(","), tag => strTrim(tag)); + _clickCaptureElements = _parseTypes(_config.trackElementTypes); + _clickCaptureInputTypes = _parseTypes(_config.trackInputTypes); }); dynamicProto(AutoCaptureHandler, this, (_self) => { _self.click = () => { @@ -101,7 +105,8 @@ export class AutoCaptureHandler implements IAutoCaptureHandler { continue; } else { // Check allowed INPUT types - var sendEvent = tagNameUpperCased === "INPUT" ? clickCaptureInputTypes[element.type.toUpperCase()] : true; + var sendEvent = tagNameUpperCased === "INPUT" ? + arrIncludes(_clickCaptureInputTypes, (element.type || "").toUpperCase()) : true; if (sendEvent) { _capturePageAction(element, overrideValues, {}, isRightClickObj); } diff --git a/extensions/applicationinsights-clickanalytics-js/src/handlers/DomContentHandler.ts b/extensions/applicationinsights-clickanalytics-js/src/handlers/DomContentHandler.ts index 2594903ae..f75223627 100644 --- a/extensions/applicationinsights-clickanalytics-js/src/handlers/DomContentHandler.ts +++ b/extensions/applicationinsights-clickanalytics-js/src/handlers/DomContentHandler.ts @@ -12,6 +12,9 @@ import { isValueAssigned, removeInvalidElements, walkUpDomChainWithElementValida const MAX_CONTENTNAME_LENGTH = 200; +// Input types whose value is a developer defined label/value rather than user entered data +const valueSafeInputTypes: { [type: string]: boolean } = { BUTTON: true, CHECKBOX: true, RADIO: true, RESET: true, SUBMIT: true }; + export class DomContentHandler implements IContentHandler { /** @@ -259,7 +262,8 @@ export class DomContentHandler implements IContentHandler { var doc = getDocument() || ({} as Document); var contentName; - switch (element.tagName) { + // tagName is lowercase for XHTML documents + switch (element.tagName.toUpperCase()) { case "A": contentName = doc.all ? element.innerText || element.innerHTML : element.text || element.innerHTML; break; @@ -267,6 +271,10 @@ export class DomContentHandler implements IContentHandler { case "AREA": contentName = element.alt; break; + case "INPUT": + contentName = (valueSafeInputTypes[(element.type || "").toUpperCase()] ? element.value : "") || + element.name || element.alt || element.id; + break; default: contentName = element.value || element.name || element.alt || element.innerText || element.id; }