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
|
// user dismissed the empty-followers promo banner
|
||||||
'invite:followersPromo:dismiss': {}
|
'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) ===
|
// === Video upload funnel (Frontend Spec section D) ===
|
||||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||||
// session end-to-end) + engine (compression engine id, e.g.
|
// session end-to-end) + engine (compression engine id, e.g.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
|
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
HLSFatalError,
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoNotFoundError,
|
VideoNotFoundError,
|
||||||
} from './VideoEmbedInnerWeb.shared'
|
} from './VideoEmbedInnerWeb.shared'
|
||||||
|
|||||||
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
|
|||||||
super('Video not found')
|
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 {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||||
import {
|
import {
|
||||||
|
HLSFatalError,
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
type VideoEmbedInnerWebProps,
|
type VideoEmbedInnerWebProps,
|
||||||
VideoNotFoundError,
|
VideoNotFoundError,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
import {Controls} from './web-controls/VideoControls'
|
import {Controls} from './web-controls/VideoControls'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
HLSFatalError,
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoNotFoundError,
|
VideoNotFoundError,
|
||||||
} from './VideoEmbedInnerWeb.shared'
|
} from './VideoEmbedInnerWeb.shared'
|
||||||
@@ -306,7 +308,7 @@ function useHLS({
|
|||||||
) {
|
) {
|
||||||
setError(new VideoNotFoundError())
|
setError(new VideoNotFoundError())
|
||||||
} else {
|
} else {
|
||||||
setError(data.error)
|
setError(new HLSFatalError(data.details, data.error))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
|
|||||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {GifPresentationControls} from './GifPresentationControls'
|
import {GifPresentationControls} from './GifPresentationControls'
|
||||||
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
|
|
||||||
function InnerWrapper({embed}: Props) {
|
function InnerWrapper({embed}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
const ref = useRef<{togglePlayback: () => void}>(null)
|
const ref = useRef<{togglePlayback: () => void}>(null)
|
||||||
|
|
||||||
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
|
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
|
||||||
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
|
|||||||
}}
|
}}
|
||||||
onError={error => {
|
onError={error => {
|
||||||
telemetryRef.current?.error(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}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
|
|||||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {
|
import {
|
||||||
|
HLSFatalError,
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoEmbedInnerWeb,
|
VideoEmbedInnerWeb,
|
||||||
VideoNotFoundError,
|
VideoNotFoundError,
|
||||||
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
|
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_WEB_FIREFOX} from '#/env'
|
import {IS_WEB_FIREFOX} from '#/env'
|
||||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
@@ -69,9 +71,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||||||
const [key, setKey] = useState(0)
|
const [key, setKey] = useState(0)
|
||||||
const renderError = useCallback(
|
const renderError = useCallback(
|
||||||
(error: unknown) => (
|
(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
|
let aspectRatio: number | undefined
|
||||||
@@ -222,23 +224,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
|
|||||||
return nearScreen ? children : null
|
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 {_} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
let showRetryButton = true
|
let showRetryButton = true
|
||||||
let text = null
|
let text = null
|
||||||
|
let errorClass: string
|
||||||
|
|
||||||
if (error instanceof VideoNotFoundError) {
|
if (error instanceof VideoNotFoundError) {
|
||||||
text = _(msg`Video not found.`)
|
text = _(msg`Video not found.`)
|
||||||
|
errorClass = 'VideoNotFoundError'
|
||||||
} else if (error instanceof HLSUnsupportedError) {
|
} else if (error instanceof HLSUnsupportedError) {
|
||||||
showRetryButton = false
|
showRetryButton = false
|
||||||
text = _(
|
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).`,
|
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 {
|
} else {
|
||||||
text = _(msg`An error occurred while loading the video. Please try again.`)
|
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 (
|
return (
|
||||||
<VideoFallback.Container>
|
<VideoFallback.Container>
|
||||||
<VideoFallback.Text>{text}</VideoFallback.Text>
|
<VideoFallback.Text>{text}</VideoFallback.Text>
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ function VideoItemInner({
|
|||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
||||||
|
|
||||||
usePlaybackTelemetry({player, active})
|
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
||||||
|
|
||||||
useEventListener(player, 'timeUpdate', evt => {
|
useEventListener(player, 'timeUpdate', evt => {
|
||||||
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
||||||
@@ -625,10 +625,13 @@ function VideoItemInner({
|
|||||||
function usePlaybackTelemetry({
|
function usePlaybackTelemetry({
|
||||||
player,
|
player,
|
||||||
active,
|
active,
|
||||||
|
playlist,
|
||||||
}: {
|
}: {
|
||||||
player: VideoPlayer
|
player: VideoPlayer
|
||||||
active: boolean
|
active: boolean
|
||||||
|
playlist: string
|
||||||
}) {
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -652,7 +655,21 @@ function usePlaybackTelemetry({
|
|||||||
if (evt.status === 'readyToPlay') {
|
if (evt.status === 'readyToPlay') {
|
||||||
telemetryRef.current?.ready()
|
telemetryRef.current?.ready()
|
||||||
} else if (evt.status === 'error') {
|
} 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