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

Session replay improvements #9

Merged
merged 3 commits into from
Sep 19, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dirty-singers-count.md
Original file line number Diff line number Diff line change
@@ -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
72 changes: 49 additions & 23 deletions packages/api/src/clickhouse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
} from './propertyTypeMappingsModel';
import {
SQLSerializer,
SearchQueryBuilder,
buildSearchColumnName,
buildSearchColumnName_OLD,
buildSearchQueryWhereCondition,
Expand Down Expand Up @@ -156,18 +157,18 @@
});

export const getLogStreamTableName = (
version: number | undefined | null,

Check warning on line 160 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'version' is defined but never used
teamId: string,

Check warning on line 161 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'teamId' is defined but never used
) => `default.${TableName.LogStream}`;

export const buildTeamLogStreamWhereCondition = (
version: number | undefined | null,

Check warning on line 165 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'version' is defined but never used
teamId: string,

Check warning on line 166 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'teamId' is defined but never used
) => SqlString.raw('(1 = 1)');

export const buildLogStreamAdditionalFilters = (
version: number | undefined | null,

Check warning on line 170 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'version' is defined but never used
teamId: string,

Check warning on line 171 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'teamId' is defined but never used
) => SettingsMap.from({});

export const healthCheck = () => client.ping();
Expand Down Expand Up @@ -404,7 +405,7 @@
// TODO: support since, until
export const fetchMetricsPropertyTypeMappings =
(intervalSecs: number) =>
async (tableVersion: number | undefined, teamId: string) => {

Check warning on line 408 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'tableVersion' is defined but never used

Check warning on line 408 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'teamId' is defined but never used
const tableName = `default.${TableName.Metric}`;
const query = SqlString.format(
`
Expand Down Expand Up @@ -590,7 +591,7 @@
}, {});
};

export const getMetricsTags = async (teamId: string) => {

Check warning on line 594 in packages/api/src/clickhouse/index.ts

View workflow job for this annotation

GitHub Actions / lint

'teamId' is defined but never used
const tableName = `default.${TableName.Metric}`;
// TODO: remove 'data_type' in the name field
const query = SqlString.format(
Expand Down Expand Up @@ -1057,27 +1058,25 @@
.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')),
Expand All @@ -1090,9 +1089,36 @@
],
);

const sessionsWithRecordingsQuery = SqlString.format(
`WITH sessions AS (${sessionsWithSearchQuery}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would be nice to merge queries for readablility (also make query parameterization migration a bit easier). but no big deal, I can clean it up later

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case this query execution is conditional (as this is a slower path), unless you were thinking of having the original query CTE duplicated?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I was thinking to have CTE inline basically

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'ma merge this as-is right now, and we can probably revisit that later - likely we'll need to tune this due to perf anyways.

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(
Expand All @@ -1104,7 +1130,7 @@
const result = await rows.json<ResponseJSON<Record<string, unknown>>>();
logger.info({
message: 'getSessions',
query,
query: executedQuery,
teamId,
took: Date.now() - ts,
});
Expand Down
12 changes: 7 additions & 5 deletions packages/api/src/clickhouse/searchQueryParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/DOMPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ${
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Form } from 'react-bootstrap';

export default function Dropdown<T extends string | number>({
name,
className,
disabled,
onChange,
options,
style,
value,
}: {
name?: string;
className?: string;
disabled?: boolean;
onChange: (value: T) => any;
Expand All @@ -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 ${
Expand Down
153 changes: 129 additions & 24 deletions packages/app/src/SessionsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Head from 'next/head';
import { Button } from 'react-bootstrap';
import { Button, Form } from 'react-bootstrap';
import {
useQueryParam,
StringParam,
Expand All @@ -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,
Expand Down Expand Up @@ -327,6 +329,8 @@ export default function SessionsPage() {
)}`;
}, []);

const [isEmailFilterExpanded, setIsEmailFilterExpanded] = useState(true);

return (
<div className="SessionsPage d-flex" style={{ height: '100vh' }}>
<Head>
Expand Down Expand Up @@ -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 });
Expand Down
Loading