Skip to content

Commit d6e0d09

Browse files
committed
feat(replay): Capture keyboard presses for special characters
1 parent be129db commit d6e0d09

File tree

4 files changed

+218
-33
lines changed

4 files changed

+218
-33
lines changed

packages/replay/src/coreHandlers/handleDom.ts

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getAttributesToRecord } from './util/getAttributesToRecord';
1010

1111
export interface DomHandlerData {
1212
name: string;
13-
event: Node | { target: Node };
13+
event: Node | { target: EventTarget };
1414
}
1515

1616
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void =
@@ -29,39 +29,21 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa
2929
addBreadcrumbEvent(replay, result);
3030
};
3131

32-
/**
33-
* An event handler to react to DOM events.
34-
* Exported for tests only.
35-
*/
36-
export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
37-
let target;
38-
let targetNode: Node | INode | undefined;
39-
40-
const isClick = handlerData.name === 'click';
41-
42-
// Accessing event.target can throw (see getsentry/raven-js#838, #768)
43-
try {
44-
targetNode = isClick ? getClickTargetNode(handlerData.event) : getTargetNode(handlerData.event);
45-
target = htmlTreeAsString(targetNode, { maxStringLength: 200 });
46-
} catch (e) {
47-
target = '<unknown>';
48-
}
49-
32+
/** Get the base DOM breadcrumb. */
33+
export function getBaseDomBreadcrumb(target: Node | INode | null, message: string): Breadcrumb {
5034
// `__sn` property is the serialized node created by rrweb
51-
const serializedNode =
52-
targetNode && '__sn' in targetNode && targetNode.__sn.type === NodeType.Element ? targetNode.__sn : null;
35+
const serializedNode = target && isRrwebNode(target) && target.__sn.type === NodeType.Element ? target.__sn : null;
5336

54-
return createBreadcrumb({
55-
category: `ui.${handlerData.name}`,
56-
message: target,
37+
return {
38+
message,
5739
data: serializedNode
5840
? {
5941
nodeId: serializedNode.id,
6042
node: {
6143
id: serializedNode.id,
6244
tagName: serializedNode.tagName,
63-
textContent: targetNode
64-
? Array.from(targetNode.childNodes)
45+
textContent: target
46+
? Array.from(target.childNodes)
6547
.map(
6648
(node: Node | INode) => '__sn' in node && node.__sn.type === NodeType.Text && node.__sn.textContent,
6749
)
@@ -73,12 +55,46 @@ export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
7355
},
7456
}
7557
: {},
58+
};
59+
}
60+
61+
/**
62+
* An event handler to react to DOM events.
63+
* Exported for tests.
64+
*/
65+
export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
66+
const { target, message } = getDomTarget(handlerData);
67+
68+
return createBreadcrumb({
69+
category: `ui.${handlerData.name}`,
70+
...getBaseDomBreadcrumb(target, message),
7671
});
7772
}
7873

79-
function getTargetNode(event: DomHandlerData['event']): Node {
74+
function getDomTarget(handlerData: DomHandlerData): { target: Node | INode | null; message: string } {
75+
const isClick = handlerData.name === 'click';
76+
77+
let message: string | undefined;
78+
let target: Node | INode | null = null;
79+
80+
// Accessing event.target can throw (see getsentry/raven-js#838, #768)
81+
try {
82+
target = isClick ? getClickTargetNode(handlerData.event) : getTargetNode(handlerData.event);
83+
message = htmlTreeAsString(target, { maxStringLength: 200 }) || '<unknown>';
84+
} catch (e) {
85+
message = '<unknown>';
86+
}
87+
88+
return { target, message };
89+
}
90+
91+
function isRrwebNode(node: EventTarget): node is INode {
92+
return '__sn' in node;
93+
}
94+
95+
function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
8096
if (isEventWithTarget(event)) {
81-
return event.target;
97+
return event.target as Node | null;
8298
}
8399

84100
return event;
@@ -90,7 +106,7 @@ const INTERACTIVE_SELECTOR = 'button,a';
90106
// If so, we use this as the target instead
91107
// This is useful because if you click on the image in <button><img></button>,
92108
// The target will be the image, not the button, which we don't want here
93-
function getClickTargetNode(event: DomHandlerData['event']): Node {
109+
function getClickTargetNode(event: DomHandlerData['event']): Node | INode | null {
94110
const target = getTargetNode(event);
95111

96112
if (!target || !(target instanceof Element)) {
@@ -101,6 +117,6 @@ function getClickTargetNode(event: DomHandlerData['event']): Node {
101117
return closestInteractive || target;
102118
}
103119

104-
function isEventWithTarget(event: unknown): event is { target: Node } {
105-
return !!(event as { target?: Node }).target;
120+
function isEventWithTarget(event: unknown): event is { target: EventTarget | null } {
121+
return typeof event === 'object' && !!event && 'target' in event;
106122
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Breadcrumb } from '@sentry/types';
2+
import { htmlTreeAsString } from '@sentry/utils';
3+
4+
import type { ReplayContainer } from '../types';
5+
import { createBreadcrumb } from '../util/createBreadcrumb';
6+
import { getBaseDomBreadcrumb } from './handleDom';
7+
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
8+
9+
/** Handle keyboard events & create breadcrumbs. */
10+
export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEvent): void {
11+
if (!replay.isEnabled()) {
12+
return;
13+
}
14+
15+
replay.triggerUserActivity();
16+
17+
const breadcrumb = getKeyboardBreadcrumb(event);
18+
19+
if (!breadcrumb) {
20+
return;
21+
}
22+
23+
addBreadcrumbEvent(replay, breadcrumb);
24+
}
25+
26+
/** exported only for tests */
27+
export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null {
28+
const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event;
29+
30+
// never capture for input fields
31+
if (!target || isInputElement(target as HTMLElement)) {
32+
return null;
33+
}
34+
35+
// Note: We do not consider shift here, as that means "uppercase"
36+
const hasModifierKey = metaKey || ctrlKey || altKey;
37+
const isCharacterKey = key.length === 1; // other keys like Escape, Tab, etc have a longer length
38+
39+
// Do not capture breadcrumb if only a word key is pressed
40+
// This could leak e.g. user input
41+
if (!hasModifierKey && isCharacterKey) {
42+
return null;
43+
}
44+
45+
const message = htmlTreeAsString(target, { maxStringLength: 200 }) || '<unknown>';
46+
const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message);
47+
48+
return createBreadcrumb({
49+
category: 'ui.keyDown',
50+
message,
51+
data: {
52+
...baseBreadcrumb.data,
53+
metaKey,
54+
shiftKey,
55+
ctrlKey,
56+
altKey,
57+
key,
58+
},
59+
});
60+
}
61+
62+
function isInputElement(target: HTMLElement): boolean {
63+
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
64+
}

packages/replay/src/replay.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SESSION_IDLE_PAUSE_DURATION,
1212
WINDOW,
1313
} from './constants';
14+
import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
1415
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
1516
import { createEventBuffer } from './eventBuffer';
1617
import { clearSession } from './session/clearSession';
@@ -701,8 +702,8 @@ export class ReplayContainer implements ReplayContainerInterface {
701702
};
702703

703704
/** Ensure page remains active when a key is pressed. */
704-
private _handleKeyboardEvent: (event: KeyboardEvent) => void = () => {
705-
this.triggerUserActivity();
705+
private _handleKeyboardEvent: (event: KeyboardEvent) => void = (event: KeyboardEvent) => {
706+
handleKeyboardEvent(this, event);
706707
};
707708

708709
/**
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { getKeyboardBreadcrumb } from '../../../src/coreHandlers/handleKeyboardEvent';
2+
3+
describe('Unit | coreHandlers | handleKeyboardEvent', () => {
4+
describe('getKeyboardBreadcrumb', () => {
5+
it('returns null for event on input', function () {
6+
const event = makeKeyboardEvent({ tagName: 'input', key: 'Escape' });
7+
const actual = getKeyboardBreadcrumb(event);
8+
expect(actual).toBeNull();
9+
});
10+
11+
it('returns null for event on textarea', function () {
12+
const event = makeKeyboardEvent({ tagName: 'textarea', key: 'Escape' });
13+
const actual = getKeyboardBreadcrumb(event);
14+
expect(actual).toBeNull();
15+
});
16+
17+
it('returns null for event on contenteditable div', function () {
18+
// JSOM does not support contentEditable properly :(
19+
const target = document.createElement('div');
20+
Object.defineProperty(target, 'isContentEditable', {
21+
get: function () {
22+
return true;
23+
},
24+
});
25+
26+
const event = makeKeyboardEvent({ target, key: 'Escape' });
27+
const actual = getKeyboardBreadcrumb(event);
28+
expect(actual).toBeNull();
29+
});
30+
31+
it('returns breadcrumb for Escape event on body', function () {
32+
const event = makeKeyboardEvent({ tagName: 'body', key: 'Escape' });
33+
const actual = getKeyboardBreadcrumb(event);
34+
expect(actual).toEqual({
35+
category: 'ui.keyDown',
36+
data: {
37+
altKey: false,
38+
ctrlKey: false,
39+
key: 'Escape',
40+
metaKey: false,
41+
shiftKey: false,
42+
},
43+
message: 'body',
44+
timestamp: expect.any(Number),
45+
type: 'default',
46+
});
47+
});
48+
49+
it.each(['a', '1', '!', '~', ']'])('returns null for %s key on body', key => {
50+
const event = makeKeyboardEvent({ tagName: 'body', key });
51+
const actual = getKeyboardBreadcrumb(event);
52+
expect(actual).toEqual(null);
53+
});
54+
55+
it.each(['a', '1', '!', '~', ']'])('returns null for %s key + Shift on body', key => {
56+
const event = makeKeyboardEvent({ tagName: 'body', key, shiftKey: true });
57+
const actual = getKeyboardBreadcrumb(event);
58+
expect(actual).toEqual(null);
59+
});
60+
61+
it.each(['a', '1', '!', '~', ']'])('returns breadcrumb for %s key + Ctrl on body', key => {
62+
const event = makeKeyboardEvent({ tagName: 'body', key, ctrlKey: true });
63+
const actual = getKeyboardBreadcrumb(event);
64+
expect(actual).toEqual({
65+
category: 'ui.keyDown',
66+
data: {
67+
altKey: false,
68+
ctrlKey: true,
69+
key,
70+
metaKey: false,
71+
shiftKey: false,
72+
},
73+
message: 'body',
74+
timestamp: expect.any(Number),
75+
type: 'default',
76+
});
77+
});
78+
});
79+
});
80+
81+
function makeKeyboardEvent({
82+
metaKey = false,
83+
shiftKey = false,
84+
ctrlKey = false,
85+
altKey = false,
86+
key,
87+
tagName,
88+
target,
89+
}: {
90+
metaKey?: boolean;
91+
shiftKey?: boolean;
92+
ctrlKey?: boolean;
93+
altKey?: boolean;
94+
key: string;
95+
tagName?: string;
96+
target?: HTMLElement;
97+
}): KeyboardEvent {
98+
const event = new KeyboardEvent('keydown', { metaKey, shiftKey, ctrlKey, altKey, key });
99+
100+
const element = target || document.createElement(tagName || 'div');
101+
element.dispatchEvent(event);
102+
103+
return event;
104+
}

0 commit comments

Comments
 (0)