Skip to content

Commit

Permalink
feat(replay): Capture keyboard presses for special characters (#8051)
Browse files Browse the repository at this point in the history
  • Loading branch information
mydea committed May 10, 2023
1 parent a70a91a commit 48ef411
Show file tree
Hide file tree
Showing 7 changed files with 354 additions and 33 deletions.
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 1000,
flushMaxDelay: 1000,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,

integrations: [window.Replay],
});
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<input id="input" />
</body>
</html>
@@ -0,0 +1,111 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../utils/fixtures';
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';

sentryTest('captures keyboard events', async ({ forceFlushReplay, getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

const reqPromise0 = waitForReplayRequest(page, 0);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
await reqPromise0;
await forceFlushReplay();

const reqPromise1 = waitForReplayRequest(page, (event, res) => {
return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.keyDown');
});
const reqPromise2 = waitForReplayRequest(page, (event, res) => {
return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.input');
});

// Trigger keyboard unfocused
await page.keyboard.press('a');
await page.keyboard.press('Control+A');

// Type unfocused
await page.keyboard.type('Hello', { delay: 10 });

// Type focused
await page.locator('#input').focus();

await page.keyboard.press('Control+A');
await page.keyboard.type('Hello', { delay: 10 });

await forceFlushReplay();
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
const { breadcrumbs: breadcrumbs2 } = getCustomRecordingEvents(await reqPromise2);

// Combine the two together
// Usually, this should all be in a single request, but it _may_ be split out, so we combine this together here.
breadcrumbs2.forEach(breadcrumb => {
if (!breadcrumbs.some(b => b.category === breadcrumb.category && b.timestamp === breadcrumb.timestamp)) {
breadcrumbs.push(breadcrumb);
}
});

expect(breadcrumbs).toEqual([
{
timestamp: expect.any(Number),
type: 'default',
category: 'ui.keyDown',
message: 'body',
data: {
nodeId: expect.any(Number),
node: {
attributes: {},
id: expect.any(Number),
tagName: 'body',
textContent: '',
},
metaKey: false,
shiftKey: false,
ctrlKey: true,
altKey: false,
key: 'Control',
},
},
{
timestamp: expect.any(Number),
type: 'default',
category: 'ui.keyDown',
message: 'body',
data: {
nodeId: expect.any(Number),
node: { attributes: {}, id: expect.any(Number), tagName: 'body', textContent: '' },
metaKey: false,
shiftKey: false,
ctrlKey: true,
altKey: false,
key: 'A',
},
},
{
timestamp: expect.any(Number),
type: 'default',
category: 'ui.input',
message: 'body > input#input',
data: {
nodeId: expect.any(Number),
node: {
attributes: { id: 'input' },
id: expect.any(Number),
tagName: 'input',
textContent: '',
},
},
},
]);
});
78 changes: 47 additions & 31 deletions packages/replay/src/coreHandlers/handleDom.ts
Expand Up @@ -10,7 +10,7 @@ import { getAttributesToRecord } from './util/getAttributesToRecord';

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

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

/**
* An event handler to react to DOM events.
* Exported for tests only.
*/
export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
let target;
let targetNode: Node | INode | undefined;

const isClick = handlerData.name === 'click';

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

/** Get the base DOM breadcrumb. */
export function getBaseDomBreadcrumb(target: Node | INode | null, message: string): Breadcrumb {
// `__sn` property is the serialized node created by rrweb
const serializedNode =
targetNode && '__sn' in targetNode && targetNode.__sn.type === NodeType.Element ? targetNode.__sn : null;
const serializedNode = target && isRrwebNode(target) && target.__sn.type === NodeType.Element ? target.__sn : null;

return createBreadcrumb({
category: `ui.${handlerData.name}`,
message: target,
return {
message,
data: serializedNode
? {
nodeId: serializedNode.id,
node: {
id: serializedNode.id,
tagName: serializedNode.tagName,
textContent: targetNode
? Array.from(targetNode.childNodes)
textContent: target
? Array.from(target.childNodes)
.map(
(node: Node | INode) => '__sn' in node && node.__sn.type === NodeType.Text && node.__sn.textContent,
)
Expand All @@ -73,12 +55,46 @@ export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
},
}
: {},
};
}

/**
* An event handler to react to DOM events.
* Exported for tests.
*/
export function handleDom(handlerData: DomHandlerData): Breadcrumb | null {
const { target, message } = getDomTarget(handlerData);

return createBreadcrumb({
category: `ui.${handlerData.name}`,
...getBaseDomBreadcrumb(target, message),
});
}

function getTargetNode(event: DomHandlerData['event']): Node {
function getDomTarget(handlerData: DomHandlerData): { target: Node | INode | null; message: string } {
const isClick = handlerData.name === 'click';

let message: string | undefined;
let target: Node | INode | null = null;

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

return { target, message };
}

function isRrwebNode(node: EventTarget): node is INode {
return '__sn' in node;
}

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

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

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

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;
}
64 changes: 64 additions & 0 deletions packages/replay/src/coreHandlers/handleKeyboardEvent.ts
@@ -0,0 +1,64 @@
import type { Breadcrumb } from '@sentry/types';
import { htmlTreeAsString } from '@sentry/utils';

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 message = htmlTreeAsString(target, { maxStringLength: 200 }) || '<unknown>';
const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message);

return createBreadcrumb({
category: 'ui.keyDown',
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
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

0 comments on commit 48ef411

Please sign in to comment.