Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): Capture keyboard presses for special characters #8051

Merged
merged 4 commits into from
May 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<input id="input" />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
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