Skip to content

Commit

Permalink
feat(replay): Capture keyboard presses for special characters
Browse files Browse the repository at this point in the history
  • Loading branch information
mydea committed May 8, 2023
1 parent 79e8e10 commit 31c4dfd
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 20 deletions.
47 changes: 29 additions & 18 deletions packages/replay/src/coreHandlers/handleDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getAttributesToRecord } from './util/getAttributesToRecord';

interface DomHandlerData {
name: string;
event: Node | { target: Node };
event: Node | { target: EventTarget };
}

export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void =
Expand All @@ -29,27 +29,24 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa
addBreadcrumbEvent(replay, result);
};

/**
* An event handler to react to DOM events.
*/
function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
let target;
let targetNode: Node | INode | undefined;
/** Get the base DOM breadcrumb. */
export function getBaseDomBreadcrumb(event: Node | { target: EventTarget | null }): Breadcrumb {
let target: string | undefined;
let targetNode: Node | INode | null = null;

// Accessing event.target can throw (see getsentry/raven-js#838, #768)
try {
targetNode = getTargetNode(handlerData);
target = htmlTreeAsString(targetNode);
targetNode = getTargetNode(event);
target = htmlTreeAsString(targetNode) || '<unknown>';
} catch (e) {
target = '<unknown>';
}

// `__sn` property is the serialized node created by rrweb
const serializedNode =
targetNode && '__sn' in targetNode && targetNode.__sn.type === NodeType.Element ? targetNode.__sn : null;
targetNode && isRrwebNode(targetNode) && targetNode.__sn.type === NodeType.Element ? targetNode.__sn : null;

return createBreadcrumb({
category: `ui.${handlerData.name}`,
return {
message: target,
data: serializedNode
? {
Expand All @@ -70,17 +67,31 @@ function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
},
}
: {},
};
}

/**
* An event handler to react to DOM events.
*/
function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
return createBreadcrumb({
category: `ui.${handlerData.name}`,
...getBaseDomBreadcrumb(handlerData.event),
});
}

function getTargetNode(handlerData: DomHandlerData): Node {
if (isEventWithTarget(handlerData.event)) {
return handlerData.event.target;
function isRrwebNode(node: Node): node is INode {
return '__sn' in node;
}

function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
if (isEventWithTarget(event)) {
return event.target as Node | null;
}

return handlerData.event;
return event;
}

function isEventWithTarget(event: unknown): event is { target: Node } {
return !!(event as { target?: Node }).target;
function isEventWithTarget(event: unknown): event is { target: EventTarget | null } {
return typeof event === 'object' && !!event && 'target' in event;
}
62 changes: 62 additions & 0 deletions packages/replay/src/coreHandlers/handleKeyboardEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Breadcrumb } from '@sentry/types';

import type { ReplayContainer } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { getBaseDomBreadcrumb } from './handleDom';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';

/** Handle keyboard events & create breadcrumbs. */
export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEvent): void {
if (!replay.isEnabled()) {
return;
}

replay.triggerUserActivity();

const breadcrumb = getKeyboardBreadcrumb(event);

if (!breadcrumb) {
return;
}

addBreadcrumbEvent(replay, breadcrumb);
}

/** exported only for tests */
export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null {
const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event;

// never capture for input fields
if (!target || isInputElement(target as HTMLElement)) {
return null;
}

// Note: We do not consider shift here, as that means "uppercase"
const hasModifierKey = metaKey || ctrlKey || altKey;
const isCharacterKey = key.length === 1; // other keys like Escape, Tab, etc have a longer length

// Do not capture breadcrumb if only a word key is pressed
// This could leak e.g. user input
if (!hasModifierKey && isCharacterKey) {
return null;
}

const baseBreadcrumb = getBaseDomBreadcrumb(event);

return createBreadcrumb({
category: 'ui.keyDown',
message: baseBreadcrumb.message,
data: {
...baseBreadcrumb.data,
metaKey,
shiftKey,
ctrlKey,
altKey,
key,
},
});
}

function isInputElement(target: HTMLElement): boolean {
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
}
5 changes: 3 additions & 2 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SESSION_IDLE_PAUSE_DURATION,
WINDOW,
} from './constants';
import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
import { createEventBuffer } from './eventBuffer';
import { clearSession } from './session/clearSession';
Expand Down Expand Up @@ -701,8 +702,8 @@ export class ReplayContainer implements ReplayContainerInterface {
};

/** Ensure page remains active when a key is pressed. */
private _handleKeyboardEvent: (event: KeyboardEvent) => void = () => {
this.triggerUserActivity();
private _handleKeyboardEvent: (event: KeyboardEvent) => void = (event: KeyboardEvent) => {
handleKeyboardEvent(this, event);
};

/**
Expand Down
104 changes: 104 additions & 0 deletions packages/replay/test/unit/coreHandlers/handleKeyboardEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { getKeyboardBreadcrumb } from '../../../src/coreHandlers/handleKeyboardEvent';

describe('Unit | coreHandlers | handleKeyboardEvent', () => {
describe('getKeyboardBreadcrumb', () => {
it('returns null for event on input', function () {
const event = makeKeyboardEvent({ tagName: 'input', key: 'Escape' });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toBeNull();
});

it('returns null for event on textarea', function () {
const event = makeKeyboardEvent({ tagName: 'textarea', key: 'Escape' });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toBeNull();
});

it('returns null for event on contenteditable div', function () {
// JSOM does not support contentEditable properly :(
const target = document.createElement('div');
Object.defineProperty(target, 'isContentEditable', {
get: function () {
return true;
},
});

const event = makeKeyboardEvent({ target, key: 'Escape' });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toBeNull();
});

it('returns breadcrumb for Escape event on body', function () {
const event = makeKeyboardEvent({ tagName: 'body', key: 'Escape' });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toEqual({
category: 'ui.keyDown',
data: {
altKey: false,
ctrlKey: false,
key: 'Escape',
metaKey: false,
shiftKey: false,
},
message: 'body',
timestamp: expect.any(Number),
type: 'default',
});
});

it.each(['a', '1', '!', '~', ']'])('returns null for %s key on body', key => {
const event = makeKeyboardEvent({ tagName: 'body', key });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toEqual(null);
});

it.each(['a', '1', '!', '~', ']'])('returns null for %s key + Shift on body', key => {
const event = makeKeyboardEvent({ tagName: 'body', key, shiftKey: true });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toEqual(null);
});

it.each(['a', '1', '!', '~', ']'])('returns breadcrumb for %s key + Ctrl on body', key => {
const event = makeKeyboardEvent({ tagName: 'body', key, ctrlKey: true });
const actual = getKeyboardBreadcrumb(event);
expect(actual).toEqual({
category: 'ui.keyDown',
data: {
altKey: false,
ctrlKey: true,
key,
metaKey: false,
shiftKey: false,
},
message: 'body',
timestamp: expect.any(Number),
type: 'default',
});
});
});
});

function makeKeyboardEvent({
metaKey = false,
shiftKey = false,
ctrlKey = false,
altKey = false,
key,
tagName,
target,
}: {
metaKey?: boolean;
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
key: string;
tagName?: string;
target?: HTMLElement;
}): KeyboardEvent {
const event = new KeyboardEvent('keydown', { metaKey, shiftKey, ctrlKey, altKey, key });

const element = target || document.createElement(tagName || 'div');
element.dispatchEvent(event);

return event;
}

0 comments on commit 31c4dfd

Please sign in to comment.