diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index ef875ff5bf..89c6632589 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1346,6 +1346,26 @@ export type Events = { // user dismissed the empty-followers promo banner 'invite:followersPromo:dismiss': {} + /** + * Fired when a video fails terminally during playback: unreachable (404), + * undecodable, or the client lacks the required codecs. Complements the + * Sentry-only video.playback spans with a countable, unsampled event. + */ + 'video:playback:failed': { + surface: 'feed' | 'immersiveFeed' + presentation: 'video' | 'gif' + /** + * Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an + * hls.js error details code (e.g. bufferAppendError), or PlayerError on + * native. + */ + errorClass: string + /** Truncated to 256 chars */ + errorMessage: string + /** HLS playlist URL, identifies the exact video for server-side lookup */ + playlist: string + } + // === Video upload funnel (Frontend Spec section D) === // Every event carries uploadId (client-generated UUID, ties one upload // session end-to-end) + engine (compression engine id, e.g. diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx index 10e8c73236..75756ac35c 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -1,6 +1,7 @@ import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared' export { + HLSFatalError, HLSUnsupportedError, VideoNotFoundError, } from './VideoEmbedInnerWeb.shared' diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index 06abf40408..d7c44b0d91 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error { super('Video not found') } } + +/** + * Fatal hls.js playback error. `detail` is the hls.js error details code + * (e.g. bufferAppendError), which buckets failures more usefully than the + * error message. + */ +export class HLSFatalError extends Error { + detail: string + constructor(detail: string, cause: Error) { + super(cause.message, {cause}) + this.detail = detail + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 72d9f3a8ba..6ece8f5a9b 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -10,6 +10,7 @@ import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {useFullscreen} from '#/components/hooks/useFullscreen' import * as BandwidthEstimate from './bandwidth-estimate' import { + HLSFatalError, HLSUnsupportedError, type VideoEmbedInnerWebProps, VideoNotFoundError, @@ -17,6 +18,7 @@ import { import {Controls} from './web-controls/VideoControls' export { + HLSFatalError, HLSUnsupportedError, VideoNotFoundError, } from './VideoEmbedInnerWeb.shared' @@ -306,7 +308,7 @@ function useHLS({ ) { setError(new VideoNotFoundError()) } else { - setError(data.error) + setError(new HLSFatalError(data.details, data.error)) } } else { console.error(data.error) diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index 66693e780c..d78aad4985 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -16,6 +16,7 @@ import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {useAnalytics} from '#/analytics' import {GifPresentationControls} from './GifPresentationControls' import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' import * as VideoFallback from './VideoEmbedInner/VideoFallback' @@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) { function InnerWrapper({embed}: Props) { const {_} = useLingui() + const ax = useAnalytics() const ref = useRef<{togglePlayback: () => void}>(null) const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>( @@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) { }} onError={error => { telemetryRef.current?.error(error) + ax.metric('video:playback:failed', { + surface: 'feed', + presentation: embed.presentation === 'gif' ? 'gif' : 'video', + errorClass: 'PlayerError', + errorMessage: error.slice(0, 256), + playlist: embed.playlist, + }) }} ref={ref} /> diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index f37ae0d664..8d5a816021 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -18,10 +18,12 @@ import {useFullscreen} from '#/components/hooks/useFullscreen' import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import { + HLSFatalError, HLSUnsupportedError, VideoEmbedInnerWeb, VideoNotFoundError, } from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' +import {useAnalytics} from '#/analytics' import {IS_WEB_FIREFOX} from '#/env' import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' @@ -69,9 +71,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { const [key, setKey] = useState(0) const renderError = useCallback( (error: unknown) => ( - setKey(key + 1)} /> + setKey(key + 1)} /> ), - [key], + [key, embed], ) let aspectRatio: number | undefined @@ -222,23 +224,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => { return nearScreen ? children : null } -function VideoError({error, retry}: {error: unknown; retry: () => void}) { +function VideoError({ + embed, + error, + retry, +}: { + embed: AppBskyEmbedVideo.View + error: unknown + retry: () => void +}) { const {_} = useLingui() + const ax = useAnalytics() let showRetryButton = true let text = null + let errorClass: string if (error instanceof VideoNotFoundError) { text = _(msg`Video not found.`) + errorClass = 'VideoNotFoundError' } else if (error instanceof HLSUnsupportedError) { showRetryButton = false text = _( msg`This video can’t be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC).`, ) + errorClass = 'HLSUnsupportedError' } else { text = _(msg`An error occurred while loading the video. Please try again.`) + if (error instanceof HLSFatalError) { + errorClass = error.detail + } else if (error instanceof Error) { + errorClass = error.name || 'Error' + } else { + errorClass = 'Unknown' + } } + const errorMessage = error instanceof Error ? error.message : String(error) + const presentation = embed.presentation === 'gif' ? 'gif' : 'video' + const playlist = embed.playlist + /* + * Fire exactly once per failure - the analytics context identity can change + * (session/geolocation updates) while this fallback stays mounted, which + * would otherwise re-run the effect and double-count. + */ + const fired = useRef(false) + useEffect(() => { + if (fired.current) return + fired.current = true + ax.metric('video:playback:failed', { + surface: 'feed', + presentation, + errorClass, + errorMessage: errorMessage.slice(0, 256), + playlist, + }) + }, [ax, presentation, playlist, errorClass, errorMessage]) + return ( {text} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 99dc03d208..2949c6a777 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -588,7 +588,7 @@ function VideoItemInner({ const {bottom} = useSafeAreaInsets() const [isReady, setIsReady] = useState(!IS_ANDROID) - usePlaybackTelemetry({player, active}) + usePlaybackTelemetry({player, active, playlist: embed.playlist}) useEventListener(player, 'timeUpdate', evt => { if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) { @@ -625,10 +625,13 @@ function VideoItemInner({ function usePlaybackTelemetry({ player, active, + playlist, }: { player: VideoPlayer active: boolean + playlist: string }) { + const ax = useAnalytics() const telemetryRef = useRef(null) useEffect(() => { @@ -652,7 +655,21 @@ function usePlaybackTelemetry({ if (evt.status === 'readyToPlay') { telemetryRef.current?.ready() } else if (evt.status === 'error') { - telemetryRef.current?.error(evt.error?.message ?? 'unknown') + const message = evt.error?.message ?? 'unknown' + telemetryRef.current?.error(message) + /* + * Adjacent players are preloaded and can error before the user ever + * swipes to them - only count failures the user actually sees. + */ + if (active) { + ax.metric('video:playback:failed', { + surface: 'immersiveFeed', + presentation: 'video', + errorClass: 'PlayerError', + errorMessage: message.slice(0, 256), + playlist, + }) + } } })