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;
}