Skip to content

Commit 31c4dfd

Browse files
committed
feat(replay): Capture keyboard presses for special characters
1 parent 79e8e10 commit 31c4dfd

File tree

4 files changed

+198
-20
lines changed

4 files changed

+198
-20
lines changed

packages/replay/src/coreHandlers/handleDom.ts

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

1111
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,27 +29,24 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa
2929
addBreadcrumbEvent(replay, result);
3030
};
3131

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

3937
// Accessing event.target can throw (see getsentry/raven-js#838, #768)
4038
try {
41-
targetNode = getTargetNode(handlerData);
42-
target = htmlTreeAsString(targetNode);
39+
targetNode = getTargetNode(event);
40+
target = htmlTreeAsString(targetNode) || '<unknown>';
4341
} catch (e) {
4442
target = '<unknown>';
4543
}
4644

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

51-
return createBreadcrumb({
52-
category: `ui.${handlerData.name}`,
49+
return {
5350
message: target,
5451
data: serializedNode
5552
? {
@@ -70,17 +67,31 @@ function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
7067
},
7168
}
7269
: {},
70+
};
71+
}
72+
73+
/**
74+
* An event handler to react to DOM events.
75+
*/
76+
function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
77+
return createBreadcrumb({
78+
category: `ui.${handlerData.name}`,
79+
...getBaseDomBreadcrumb(handlerData.event),
7380
});
7481
}
7582

76-
function getTargetNode(handlerData: DomHandlerData): Node {
77-
if (isEventWithTarget(handlerData.event)) {
78-
return handlerData.event.target;
83+
function isRrwebNode(node: Node): node is INode {
84+
return '__sn' in node;
85+
}
86+
87+
function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
88+
if (isEventWithTarget(event)) {
89+
return event.target as Node | null;
7990
}
8091

81-
return handlerData.event;
92+
return event;
8293
}
8394

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

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)