add client event for failed video playback (#11170)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
|
||||
|
||||
export {
|
||||
HLSFatalError,
|
||||
HLSUnsupportedError,
|
||||
VideoNotFoundError,
|
||||
} from './VideoEmbedInnerWeb.shared'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) => (
|
||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||
<VideoError embed={embed} error={error} retry={() => 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 (
|
||||
<VideoFallback.Container>
|
||||
<VideoFallback.Text>{text}</VideoFallback.Text>
|
||||
|
||||
@@ -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<PlaybackTelemetry | null>(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,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user