Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions extensions/applicationinsights-clickanalytics-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>` `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 `<input>` 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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]);

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const defaultValues: IConfigDefaults<IClickAnalyticsConfiguration> = 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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}));
}

Expand All @@ -167,7 +166,6 @@ export class ClickAnalyticsPlugin extends BaseTelemetryPlugin {
_pageAction = null;
_autoCaptureHandler = null;
_contentHandler = null;
_autoCapture = false;
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ export interface IClickAnalyticsConfiguration {
* If set, it will combine with the default element types.
*/
trackElementTypes?: string;

/**
* A comma separated list of `<input>` element `type` values that are automatically captured when `INPUT` is included in
* `trackElementTypes`. Clicks on `<input>` 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

/**
Expand Down Expand Up @@ -259,14 +262,19 @@ 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;
case "IMG":
case "AREA":
contentName = element.alt;
break;
case "INPUT":
Comment thread
hectorhdzg marked this conversation as resolved.
Resolved
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;
}
Expand Down
Loading