From 8a90310a4971ddc2f1197180161979b50855238e Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 16 Jul 2026 12:11:20 -0400 Subject: [PATCH] add client event for failed video playback --- src/analytics/metrics/types.ts | 20 ++++++++++ .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 15 ++++++- .../Post/Embed/VideoEmbed/index.tsx | 9 +++++ .../Post/Embed/VideoEmbed/index.web.tsx | 40 +++++++++++++++++-- src/screens/VideoFeed/index.tsx | 21 +++++++++- 5 files changed, 99 insertions(+), 6 deletions(-) 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.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index b97317dfbf..3d5affc8d4 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -144,6 +144,19 @@ export class VideoNotFoundError extends Error { } } +/** + * 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 + } +} + type CachedPromise = Promise & {value: undefined | T} const promiseForHls = import( // @ts-ignore @@ -315,7 +328,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..0b4640e968 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,55 @@ 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 + useEffect(() => { + 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, + }) + } } })