add video playback observability to sentry
This commit is contained in:
committed by
Samuel Newman
parent
4778142ee8
commit
0d2883ebc1
@@ -26,12 +26,18 @@ export function VideoEmbedInnerNative({
|
||||
setStatus,
|
||||
setIsLoading,
|
||||
setIsActive,
|
||||
onError,
|
||||
}: {
|
||||
ref: React.Ref<{togglePlayback: () => void}>
|
||||
embed: AppBskyEmbedVideo.View
|
||||
setStatus: (status: 'playing' | 'paused') => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setIsActive: (isActive: boolean) => void
|
||||
/**
|
||||
* Called with the native error message before the component throws to the
|
||||
* surrounding error boundary.
|
||||
*/
|
||||
onError?: (error: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const videoRef = useRef<BlueskyVideoView>(null)
|
||||
@@ -81,6 +87,7 @@ export function VideoEmbedInnerNative({
|
||||
setTimeRemaining(e.nativeEvent.timeRemaining)
|
||||
}}
|
||||
onError={e => {
|
||||
onError?.(e.nativeEvent.error)
|
||||
setError(e.nativeEvent.error)
|
||||
}}
|
||||
ref={videoRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
@@ -6,6 +6,10 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
createPlaybackTelemetry,
|
||||
type PlaybackTelemetry,
|
||||
} from '#/lib/media/video/playbackTelemetry'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, platform} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
@@ -75,6 +79,17 @@ function InnerWrapper({embed}: Props) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const showSpinner = useThrottledValue(isActive && isLoading, 100)
|
||||
|
||||
/*
|
||||
* Created lazily on first activation so videos that are never scrolled into
|
||||
* the active position cost nothing.
|
||||
*/
|
||||
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
telemetryRef.current?.deactivated()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showOverlay =
|
||||
!isActive ||
|
||||
isLoading ||
|
||||
@@ -89,9 +104,33 @@ function InnerWrapper({embed}: Props) {
|
||||
<>
|
||||
<VideoEmbedInnerNative
|
||||
embed={embed}
|
||||
setStatus={setStatus}
|
||||
setIsLoading={setIsLoading}
|
||||
setIsActive={setIsActive}
|
||||
setStatus={s => {
|
||||
setStatus(s)
|
||||
if (s === 'playing') {
|
||||
telemetryRef.current?.playing()
|
||||
}
|
||||
}}
|
||||
setIsLoading={loading => {
|
||||
setIsLoading(loading)
|
||||
if (!loading) {
|
||||
telemetryRef.current?.ready()
|
||||
}
|
||||
}}
|
||||
setIsActive={active => {
|
||||
setIsActive(active)
|
||||
if (active) {
|
||||
telemetryRef.current ??= createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
|
||||
})
|
||||
telemetryRef.current.activated()
|
||||
} else {
|
||||
telemetryRef.current?.deactivated()
|
||||
}
|
||||
}}
|
||||
onError={error => {
|
||||
telemetryRef.current?.error(error)
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
<ImageBackground
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {createPlaybackTelemetry} from '../playbackTelemetry'
|
||||
|
||||
const mockSpan = {
|
||||
setAttribute: jest.fn(),
|
||||
end: jest.fn(),
|
||||
}
|
||||
|
||||
jest.mock('#/logger/sentry/lib', () => ({
|
||||
Sentry: {
|
||||
startInactiveSpan: jest.fn(() => mockSpan),
|
||||
},
|
||||
}))
|
||||
|
||||
const {Sentry}: {Sentry: {startInactiveSpan: jest.Mock}} = jest.requireMock(
|
||||
'#/logger/sentry/lib',
|
||||
)
|
||||
|
||||
describe('createPlaybackTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts a root span on activation with surface and presentation', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated()
|
||||
expect(Sentry.startInactiveSpan).toHaveBeenCalledWith({
|
||||
name: 'video.playback',
|
||||
op: 'video.playback',
|
||||
attributes: {surface: 'feed', presentation: 'video'},
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing before activation', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.ready()
|
||||
telemetry.playing()
|
||||
telemetry.deactivated()
|
||||
expect(Sentry.startInactiveSpan).not.toHaveBeenCalled()
|
||||
expect(mockSpan.end).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores duplicate activations while a span is open', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated()
|
||||
telemetry.activated()
|
||||
expect(Sentry.startInactiveSpan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records ready and playing times once', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated()
|
||||
telemetry.ready()
|
||||
telemetry.ready()
|
||||
telemetry.playing()
|
||||
telemetry.playing()
|
||||
const calls = mockSpan.setAttribute.mock.calls as [string, unknown][]
|
||||
const attrs = calls.map(c => c[0])
|
||||
expect(attrs.filter(a => a === 'timeToReadyMs')).toHaveLength(1)
|
||||
expect(attrs.filter(a => a === 'timeToFirstPlayMs')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ends the span with ok outcome on deactivation, idempotently', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated()
|
||||
telemetry.deactivated()
|
||||
telemetry.deactivated()
|
||||
expect(mockSpan.setAttribute).toHaveBeenCalledWith('outcome', 'ok')
|
||||
expect(mockSpan.end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ends the span with error outcome and message', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'feed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated()
|
||||
telemetry.error('AVFoundationErrorDomain -11850')
|
||||
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
|
||||
'errorMessage',
|
||||
'AVFoundationErrorDomain -11850',
|
||||
)
|
||||
expect(mockSpan.setAttribute).toHaveBeenCalledWith('outcome', 'error')
|
||||
expect(mockSpan.end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a new span for a new activation window', () => {
|
||||
const telemetry = createPlaybackTelemetry({
|
||||
surface: 'immersiveFeed',
|
||||
presentation: 'video',
|
||||
})
|
||||
telemetry.activated({preloaded: true})
|
||||
telemetry.deactivated()
|
||||
telemetry.activated({preloaded: false})
|
||||
expect(Sentry.startInactiveSpan).toHaveBeenCalledTimes(2)
|
||||
expect(Sentry.startInactiveSpan).toHaveBeenLastCalledWith({
|
||||
name: 'video.playback',
|
||||
op: 'video.playback',
|
||||
attributes: {
|
||||
surface: 'immersiveFeed',
|
||||
presentation: 'video',
|
||||
preloaded: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import {Sentry} from '#/logger/sentry/lib'
|
||||
|
||||
/**
|
||||
* Where the video is being played.
|
||||
*/
|
||||
export type PlaybackSurface = 'feed' | 'immersiveFeed'
|
||||
|
||||
export type PlaybackTelemetry = {
|
||||
activated: (opts?: {preloaded?: boolean}) => void
|
||||
ready: () => void
|
||||
playing: () => void
|
||||
error: (e: unknown) => void
|
||||
deactivated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentry-only observability for video playback in feeds. Opens a root span
|
||||
* per activation window (the video becomes the active, autoplaying one) and
|
||||
* ends it on deactivation. Because these are root spans, the SDK attaches JS
|
||||
* stall and slow/frozen frame measurements to them, capturing scroll
|
||||
* smoothness while a video is on screen.
|
||||
*
|
||||
* Does not report to the analytics pipeline, only Sentry.
|
||||
*/
|
||||
export function createPlaybackTelemetry({
|
||||
surface,
|
||||
presentation,
|
||||
}: {
|
||||
surface: PlaybackSurface
|
||||
presentation: 'video' | 'gif'
|
||||
}): PlaybackTelemetry {
|
||||
let span: ReturnType<typeof Sentry.startInactiveSpan> | undefined
|
||||
let activatedAt = 0
|
||||
let sawReady = false
|
||||
let sawPlaying = false
|
||||
|
||||
function end(outcome: 'ok' | 'error') {
|
||||
if (!span) return
|
||||
span.setAttribute('outcome', outcome)
|
||||
span.end()
|
||||
span = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
activated(opts) {
|
||||
if (span) return
|
||||
activatedAt = Date.now()
|
||||
sawReady = false
|
||||
sawPlaying = false
|
||||
span = Sentry.startInactiveSpan({
|
||||
name: 'video.playback',
|
||||
op: 'video.playback',
|
||||
attributes: {
|
||||
surface,
|
||||
presentation,
|
||||
...(opts?.preloaded !== undefined && {preloaded: opts.preloaded}),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
ready() {
|
||||
if (!span || sawReady) return
|
||||
sawReady = true
|
||||
span.setAttribute('timeToReadyMs', Date.now() - activatedAt)
|
||||
},
|
||||
|
||||
playing() {
|
||||
if (!span || sawPlaying) return
|
||||
sawPlaying = true
|
||||
span.setAttribute('timeToFirstPlayMs', Date.now() - activatedAt)
|
||||
},
|
||||
|
||||
error(e) {
|
||||
if (!span) return
|
||||
span.setAttribute('errorMessage', String(e).slice(0, 256))
|
||||
end('error')
|
||||
},
|
||||
|
||||
deactivated() {
|
||||
end('ok')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {init} from '@sentry/react-native'
|
||||
import {getGlobalScope, init} from '@sentry/react-native'
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
@@ -30,4 +30,19 @@ init({
|
||||
*/
|
||||
attachStacktrace: false,
|
||||
sampleRate: env.IS_INTERNAL ? 1.0 : 0.1,
|
||||
/**
|
||||
* Sample rate for performance spans (video playback, video upload). Setting
|
||||
* this also enables the SDK's default stall and slow/frozen frame tracking,
|
||||
* whose measurements attach to every root span.
|
||||
*/
|
||||
tracesSampleRate: env.IS_INTERNAL ? 1.0 : 0.01,
|
||||
})
|
||||
|
||||
/*
|
||||
* Events already carry react_native_context.fabric, but a tag is easier to
|
||||
* filter and dashboard on. Detection matches the SDK's own isFabricEnabled.
|
||||
*/
|
||||
getGlobalScope().setTag(
|
||||
'new_arch',
|
||||
(global as {nativeFabricUIManager?: unknown}).nativeFabricUIManager != null,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,10 @@ import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {
|
||||
createPlaybackTelemetry,
|
||||
type PlaybackTelemetry,
|
||||
} from '#/lib/media/video/playbackTelemetry'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NavigationProp,
|
||||
@@ -551,7 +555,7 @@ let VideoItem = ({
|
||||
<>
|
||||
<VideoItemPlaceholder embed={embed} />
|
||||
{shouldRenderVideo && player && (
|
||||
<VideoItemInner player={player} embed={embed} />
|
||||
<VideoItemInner player={player} embed={embed} active={active} />
|
||||
)}
|
||||
{moderation && (
|
||||
<Overlay
|
||||
@@ -575,13 +579,17 @@ VideoItem = memo(VideoItem)
|
||||
function VideoItemInner({
|
||||
player,
|
||||
embed,
|
||||
active,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
embed: AppBskyEmbedVideo.View
|
||||
active: boolean
|
||||
}) {
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
||||
|
||||
usePlaybackTelemetry({player, active})
|
||||
|
||||
useEventListener(player, 'timeUpdate', evt => {
|
||||
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
||||
setIsReady(true)
|
||||
@@ -609,6 +617,52 @@ function VideoItemInner({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Sentry playback span while this item is the active video. Adjacent
|
||||
* players are preloaded by updateVideoState, so record whether the player was
|
||||
* already ready at activation to separate load time from swipe latency.
|
||||
*/
|
||||
function usePlaybackTelemetry({
|
||||
player,
|
||||
active,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
active: boolean
|
||||
}) {
|
||||
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
telemetryRef.current ??= createPlaybackTelemetry({
|
||||
surface: 'immersiveFeed',
|
||||
presentation: 'video',
|
||||
})
|
||||
const telemetry = telemetryRef.current
|
||||
const preloaded = player.status === 'readyToPlay'
|
||||
telemetry.activated({preloaded})
|
||||
if (preloaded) {
|
||||
telemetry.ready()
|
||||
}
|
||||
return () => {
|
||||
telemetry.deactivated()
|
||||
}
|
||||
}, [active, player])
|
||||
|
||||
useEventListener(player, 'statusChange', evt => {
|
||||
if (evt.status === 'readyToPlay') {
|
||||
telemetryRef.current?.ready()
|
||||
} else if (evt.status === 'error') {
|
||||
telemetryRef.current?.error(evt.error?.message ?? 'unknown')
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(player, 'playingChange', evt => {
|
||||
if (evt.isPlaying) {
|
||||
telemetryRef.current?.playing()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function ModerationOverlay({
|
||||
embed,
|
||||
onPressShow,
|
||||
|
||||
Reference in New Issue
Block a user