Skip to content

Commit 48ef411

Browse files
authored
feat(replay): Capture keyboard presses for special characters (#8051)
1 parent a70a91a commit 48ef411

File tree

7 files changed

+354
-33
lines changed

7 files changed

+354
-33
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 1000,
6+
flushMaxDelay: 1000,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
11+
sampleRate: 0,
12+
replaysSessionSampleRate: 1.0,
13+
replaysOnErrorSampleRate: 0.0,
14+
15+
integrations: [window.Replay],
16+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<input id="input" />
8+
</body>
9+
</html>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
5+
6+
sentryTest('captures keyboard events', async ({ forceFlushReplay, getLocalTestPath, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const reqPromise0 = waitForReplayRequest(page, 0);
12+
13+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
14+
return route.fulfill({
15+
status: 200,
16+
contentType: 'application/json',
17+
body: JSON.stringify({ id: 'test-id' }),
18+
});
19+
});
20+
21+
const url = await getLocalTestPath({ testDir: __dirname });
22+
23+
await page.goto(url);
24+
await reqPromise0;
25+
await forceFlushReplay();
26+
27+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
28+
return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.keyDown');
29+
});
30+
const reqPromise2 = waitForReplayRequest(page, (event, res) => {
31+
return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.input');
32+
});
33+
34+
// Trigger keyboard unfocused
35+
await page.keyboard.press('a');
36+
await page.keyboard.press('Control+A');
37+
38+
// Type unfocused
39+
await page.keyboard.type('Hello', { delay: 10 });
40+
41+
// Type focused
42+
await page.locator('#input').focus();
43+
44+
await page.keyboard.press('Control+A');
45+
await page.keyboard.type('Hello', { delay: 10 });
46+
47+
await forceFlushReplay();
48+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
49+
const { breadcrumbs: breadcrumbs2 } = getCustomRecordingEvents(await reqPromise2);
50+
51+
// Combine the two together
52+
// Usually, this should all be in a single request, but it _may_ be split out, so we combine this together here.
53+
breadcrumbs2.forEach(breadcrumb => {
54+
if (!breadcrumbs.some(b => b.category === breadcrumb.category && b.timestamp === breadcrumb.timestamp)) {
55+
breadcrumbs.push(breadcrumb);
56+
}
57+
});
58+
59+
expect(breadcrumbs).toEqual([
60+
{
61+
timestamp: expect.any(Number),
62+
type: 'default',
63+
category: 'ui.keyDown',
64+
message: 'body',
65+
data: {
66+
nodeId: expect.any(Number),
67+
node: {
68+
attributes: {},
69+
id: expect.any(Number),
70+
tagName: 'body',
71+
textContent: '',
72+
},
73+
metaKey: false,
74+
shiftKey: false,
75+
ctrlKey: true,
76+
altKey: false,
77+
key: 'Control',
78+
},
79+
},
80+
{
81+
timestamp: expect.any(Number),
82+
type: 'default',
83+
category: 'ui.keyDown',
84+
message: 'body',
85+
data: {
86+
nodeId: expect.any(Number),
87+
node: { attributes: {}, id: expect.any(Number), tagName: 'body', textContent: '' },
88+
metaKey: false,
89+
shiftKey: false,
90+
ctrlKey: true,
91+
altKey: false,
92+
key: 'A',
93+
},
94+
},
95+
{
96+
timestamp: expect.any(Number),
97+
type: 'default',
98+
category: 'ui.input',
99+
message: 'body > input#input',
100+
data: {
101+
nodeId: expect.any(Number),
102+
node: {
103+
attributes: { id: 'input' },
104+
id: expect.any(Number),
105+
tagName: 'input',
106+
textContent: '',
107+
},
108+
},
109+
},
110+
]);
111+
});

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
/**

0 commit comments

Comments
 (0)