import {useEffect, useMemo, useState} from 'react'; import first from 'lodash/first'; import fetchReplayClicks from 'sentry/utils/replays/fetchReplayClicks'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import useApi from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; export type TimeOffsetLocationQueryParams = { /** * The time when the event happened. * Anything that can be parsed by `new Date()`; for example a timestamp in ms * or an ISO 8601 formatted string. */ event_t?: string; /** * The query that was used on the index page. If it includes `click.*` fields * then we will use those to lookup a list of `offsetMs` values */ query?: string; /** * A specific offset into the replay. Number of seconds. * Should be less than the duration of the replay */ t?: string; }; type Opts = { /** * The organization name you'll see in the browser url */ orgSlug: string; /** * The project slug of the replayRecord */ projectSlug: string | null; /** * The replayId */ replayId: string; /** * The start timestamp of the replay. * Used to calculate the offset into the replay from an event timestamp */ replayStartTimestampMs?: number; }; type Result = | undefined | { offsetMs: number; highlight?: { nodeId: number; annotation?: string; spotlight?: boolean; }; }; const ZERO_OFFSET = {offsetMs: 0}; function fromOffset({offsetSec}): Result { if (offsetSec === undefined) { // Not using this strategy return undefined; } return {offsetMs: Number(offsetSec) * 1000}; } function fromEventTimestamp({eventTimestamp, replayStartTimestampMs}): Result { if (eventTimestamp === undefined) { // Not using this strategy return undefined; } if (replayStartTimestampMs !== undefined) { const eventTimestampMs = new Date(eventTimestamp).getTime(); if (eventTimestampMs >= replayStartTimestampMs) { return {offsetMs: eventTimestampMs - replayStartTimestampMs}; } } // The strategy failed, default to something safe return ZERO_OFFSET; } async function fromListPageQuery({ api, listPageQuery, orgSlug, replayId, projectSlug, replayStartTimestampMs, }): Promise { if (listPageQuery === undefined) { // Not using this strategy return undefined; } // Check if there is even any `click.*` fields in the query string const search = new MutableSearch(listPageQuery); const isClickSearch = search.getFilterKeys().some(key => key.startsWith('click.')); if (!isClickSearch) { // There was a search, but not for clicks, so lets skip this strategy. return undefined; } if (replayStartTimestampMs === undefined) { // Using the strategy, but we must wait for replayStartTimestampMs to appear return ZERO_OFFSET; } if (!projectSlug) { return undefined; } const results = await fetchReplayClicks({ api, orgSlug, projectSlug, replayId, query: listPageQuery, }); if (!results.clicks.length) { return ZERO_OFFSET; } try { const firstResult = first(results.clicks)!; const firstTimestamp = firstResult!.timestamp; const nodeId = firstResult!.node_id; const firstTimestmpMs = new Date(firstTimestamp).getTime(); return { highlight: { annotation: listPageQuery, nodeId, spotlight: true, }, offsetMs: firstTimestmpMs - replayStartTimestampMs, }; } catch { return ZERO_OFFSET; } } function useInitialTimeOffsetMs({ orgSlug, replayId, projectSlug, replayStartTimestampMs, }: Opts): Result { const api = useApi(); const { query: {event_t: eventTimestamp, query: listPageQuery, t: offsetSec}, } = useLocation(); const [timestamp, setTimestamp] = useState(undefined); // The different strategies for getting a time offset into the replay (what // time to start the replay at) // Each strategy should return time in milliseconds const offsetTimeMs = useMemo(() => fromOffset({offsetSec}), [offsetSec]); const eventTimeMs = useMemo( () => fromEventTimestamp({eventTimestamp, replayStartTimestampMs}), [eventTimestamp, replayStartTimestampMs] ); const queryTimeMs = useMemo( () => eventTimestamp === undefined ? fromListPageQuery({ api, listPageQuery, orgSlug, replayId, projectSlug, replayStartTimestampMs, }) : undefined, [ api, eventTimestamp, listPageQuery, orgSlug, replayId, projectSlug, replayStartTimestampMs, ] ); useEffect(() => { Promise.resolve(undefined) .then(definedOrDefault(offsetTimeMs)) .then(definedOrDefault(eventTimeMs)) .then(definedOrDefault(queryTimeMs)) .then(definedOrDefault(ZERO_OFFSET)) .then(setTimestamp); }, [offsetTimeMs, eventTimeMs, queryTimeMs, projectSlug]); return timestamp; } function definedOrDefault(dflt: T | undefined | Promise) { return (val: T | undefined) => { return val ?? dflt; }; } export default useInitialTimeOffsetMs;