mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Session replay improvements (#9)
Co-authored-by: Warren <5959690+wrn14897@users.noreply.github.com>
This commit is contained in:
parent
2352404c68
commit
bd37a5e9b5
6 changed files with 201 additions and 52 deletions
7
.changeset/dirty-singers-count.md
Normal file
7
.changeset/dirty-singers-count.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
'@hyperdx/api': patch
|
||||
'@hyperdx/app': patch
|
||||
---
|
||||
|
||||
Filter out empty session replays from session replay search, add email filter to
|
||||
session replay UI
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from './propertyTypeMappingsModel';
|
||||
import {
|
||||
SQLSerializer,
|
||||
SearchQueryBuilder,
|
||||
buildSearchColumnName,
|
||||
buildSearchColumnName_OLD,
|
||||
buildSearchQueryWhereCondition,
|
||||
|
|
@ -1057,27 +1058,25 @@ export const getSessions = async ({
|
|||
.map(props => buildCustomColumn(props[0], props[1]))
|
||||
.map(column => SqlString.raw(column));
|
||||
|
||||
const query = SqlString.format(
|
||||
`
|
||||
SELECT
|
||||
MAX(timestamp) AS maxTimestamp,
|
||||
MIN(timestamp) AS minTimestamp,
|
||||
count() AS sessionCount,
|
||||
countIf(?='user-interaction') AS interactionCount,
|
||||
countIf(severity_text = 'error') AS errorCount,
|
||||
? AS sessionId,
|
||||
?
|
||||
FROM ??
|
||||
WHERE ? AND (?)
|
||||
GROUP BY sessionId
|
||||
${
|
||||
// If the user is giving us an explicit query, we don't need to filter out sessions with no interactions
|
||||
// this is because the events that match the query might not be user interactions, and we'll just show 0 results otherwise.
|
||||
q.length === 0 ? 'HAVING interactionCount > 0' : ''
|
||||
}
|
||||
ORDER BY maxTimestamp DESC
|
||||
LIMIT ?, ?
|
||||
`,
|
||||
const sessionsWithSearchQuery = SqlString.format(
|
||||
`SELECT
|
||||
MAX(timestamp) AS maxTimestamp,
|
||||
MIN(timestamp) AS minTimestamp,
|
||||
count() AS sessionCount,
|
||||
countIf(?='user-interaction') AS interactionCount,
|
||||
countIf(severity_text = 'error') AS errorCount,
|
||||
? AS sessionId,
|
||||
?
|
||||
FROM ??
|
||||
WHERE ? AND (?)
|
||||
GROUP BY sessionId
|
||||
${
|
||||
// If the user is giving us an explicit query, we don't need to filter out sessions with no interactions
|
||||
// this is because the events that match the query might not be user interactions, and we'll just show 0 results otherwise.
|
||||
q.length === 0 ? 'HAVING interactionCount > 0' : ''
|
||||
}
|
||||
ORDER BY maxTimestamp DESC
|
||||
LIMIT ?, ?`,
|
||||
[
|
||||
SqlString.raw(buildSearchColumnName('string', 'component')),
|
||||
SqlString.raw(buildSearchColumnName('string', 'rum_session_id')),
|
||||
|
|
@ -1090,9 +1089,36 @@ export const getSessions = async ({
|
|||
],
|
||||
);
|
||||
|
||||
const sessionsWithRecordingsQuery = SqlString.format(
|
||||
`WITH sessions AS (${sessionsWithSearchQuery}),
|
||||
sessionIdsWithRecordings AS (
|
||||
SELECT DISTINCT _rum_session_id as sessionId
|
||||
FROM ??
|
||||
WHERE span_name='record init'
|
||||
AND (_rum_session_id IN (SELECT sessions.sessionId FROM sessions))
|
||||
AND (?)
|
||||
)
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE sessions.sessionId IN (
|
||||
SELECT sessionIdsWithRecordings.sessionId FROM sessionIdsWithRecordings
|
||||
)`,
|
||||
[
|
||||
tableName,
|
||||
SqlString.raw(SearchQueryBuilder.timestampInBetween(startTime, endTime)),
|
||||
],
|
||||
);
|
||||
|
||||
// If the user specifes a query, we need to filter out returned sessions
|
||||
// by the 'record init' event being included so we don't return "blank"
|
||||
// sessions, this can be optimized once we record background status
|
||||
// of all events in the RUM package
|
||||
const executedQuery =
|
||||
q.length === 0 ? sessionsWithSearchQuery : sessionsWithRecordingsQuery;
|
||||
|
||||
const ts = Date.now();
|
||||
const rows = await client.query({
|
||||
query,
|
||||
query: executedQuery,
|
||||
format: 'JSON',
|
||||
clickhouse_settings: {
|
||||
additional_table_filters: buildLogStreamAdditionalFilters(
|
||||
|
|
@ -1104,7 +1130,7 @@ export const getSessions = async ({
|
|||
const result = await rows.json<ResponseJSON<Record<string, unknown>>>();
|
||||
logger.info({
|
||||
message: 'getSessions',
|
||||
query,
|
||||
query: executedQuery,
|
||||
teamId,
|
||||
took: Date.now() - ts,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -588,13 +588,15 @@ export class SearchQueryBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
static timestampInBetween(startTime: number, endTime: number) {
|
||||
return `_timestamp_sort_key >= ${msToBigIntNs(
|
||||
startTime,
|
||||
)} AND _timestamp_sort_key < ${msToBigIntNs(endTime)}`;
|
||||
}
|
||||
|
||||
// startTime and endTime are unix in ms
|
||||
timestampInBetween(startTime: number, endTime: number) {
|
||||
this.and(
|
||||
`_timestamp_sort_key >= ${msToBigIntNs(
|
||||
startTime,
|
||||
)} AND _timestamp_sort_key < ${msToBigIntNs(endTime)}`,
|
||||
);
|
||||
this.and(SearchQueryBuilder.timestampInBetween(startTime, endTime));
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -384,6 +384,12 @@ export default function DOMPlayer({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isReplayFullyLoaded && replayer.current == null && (
|
||||
<div className="d-flex align-items-center justify-content-center bg-hdx-dark p-4 text-center text-muted">
|
||||
No replay available for this session, most likely due to this session
|
||||
starting and ending in a background tab.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={wrapper}
|
||||
className={`player-wrapper overflow-hidden ${
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Form } from 'react-bootstrap';
|
||||
|
||||
export default function Dropdown<T extends string | number>({
|
||||
name,
|
||||
className,
|
||||
disabled,
|
||||
onChange,
|
||||
|
|
@ -8,6 +9,7 @@ export default function Dropdown<T extends string | number>({
|
|||
style,
|
||||
value,
|
||||
}: {
|
||||
name?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => any;
|
||||
|
|
@ -17,6 +19,7 @@ export default function Dropdown<T extends string | number>({
|
|||
}) {
|
||||
return (
|
||||
<Form.Select
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
role="button"
|
||||
className={`shadow-none fw-bold ${
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Head from 'next/head';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import {
|
||||
useQueryParam,
|
||||
StringParam,
|
||||
|
|
@ -8,10 +8,12 @@ import {
|
|||
} from 'use-query-params';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NumberParam } from 'serialize-query-params';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import api from './api';
|
||||
import SearchTimeRangePicker from './SearchTimeRangePicker';
|
||||
import AppNav from './AppNav';
|
||||
import Dropdown from './Dropdown';
|
||||
import {
|
||||
formatDistanceToNowStrictShort,
|
||||
formatHumanReadableDate,
|
||||
|
|
@ -327,6 +329,8 @@ export default function SessionsPage() {
|
|||
)}`;
|
||||
}, []);
|
||||
|
||||
const [isEmailFilterExpanded, setIsEmailFilterExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="SessionsPage d-flex" style={{ height: '100vh' }}>
|
||||
<Head>
|
||||
|
|
@ -382,30 +386,131 @@ export default function SessionsPage() {
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setSearchedQuery(inputQuery);
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={inputQuery}
|
||||
onChange={value => setInputQuery(value)}
|
||||
onSearch={() => {}}
|
||||
placeholder="Search for a session by email, id..."
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="d-flex align-items-center me-2">
|
||||
<span
|
||||
className="rounded fs-8 text-nowrap border border-dark p-2"
|
||||
style={{
|
||||
borderTopRightRadius: '0 !important',
|
||||
borderBottomRightRadius: '0 !important',
|
||||
}}
|
||||
title="Filters"
|
||||
>
|
||||
<i className="bi bi-funnel"></i>
|
||||
</span>{' '}
|
||||
<div className="d-flex align-items-center w-100 flex-grow-1">
|
||||
<Button
|
||||
variant="dark"
|
||||
type="button"
|
||||
className="text-muted-hover d-flex align-items-center fs-8 p-2"
|
||||
onClick={() => setIsEmailFilterExpanded(v => !v)}
|
||||
style={
|
||||
isEmailFilterExpanded
|
||||
? {
|
||||
borderRadius: 0,
|
||||
}
|
||||
: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
Email
|
||||
</Button>
|
||||
{isEmailFilterExpanded && (
|
||||
<form
|
||||
className="d-flex"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
// TODO: Transition to react-hook-form or controlled state
|
||||
// @ts-ignore
|
||||
const value = e.target.value.value;
|
||||
// @ts-ignore
|
||||
const op = e.target.op.value;
|
||||
|
||||
setSearchedQuery(
|
||||
(
|
||||
inputQuery +
|
||||
(op === 'is'
|
||||
? ` userEmail:"${value}"`
|
||||
: op === 'is_not'
|
||||
? ` -userEmail:"${value}"`
|
||||
: ` userEmail:${value}`)
|
||||
).trim(),
|
||||
);
|
||||
|
||||
toast.success('Added filter to search query');
|
||||
inputRef.current?.focus();
|
||||
|
||||
// @ts-ignore
|
||||
e.target.value.value = '';
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
name="op"
|
||||
className="border border-dark fw-normal fs-8 p-2"
|
||||
style={{ borderRadius: 0, minWidth: 100 }}
|
||||
options={[
|
||||
{
|
||||
value: 'contains',
|
||||
text: 'contains',
|
||||
},
|
||||
{ value: 'is', text: 'is' },
|
||||
{ value: 'is_not', text: 'is not' },
|
||||
]}
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Form.Control
|
||||
type="text"
|
||||
id="value"
|
||||
name="value"
|
||||
className="fs-8 p-2 w-100"
|
||||
style={{ borderRadius: 0 }}
|
||||
placeholder="value"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center fs-8 p-2"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="d-flex align-items-center flex-grow-1"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setSearchedQuery(inputQuery);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<div style={{ minHeight: 0 }}>
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={inputQuery}
|
||||
onChange={value => setInputQuery(value)}
|
||||
onSearch={() => {}}
|
||||
placeholder="Search for a session by email, id..."
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ minHeight: 0 }} className="mt-4">
|
||||
<SessionCardList
|
||||
onClick={(sessionId, dateRange) => {
|
||||
setSelectedSession({ id: sessionId, dateRange });
|
||||
|
|
|
|||
Loading…
Reference in a new issue