add video playback observability to sentry

This commit is contained in:
vineyardbovines
2026-07-08 11:07:55 -04:00
committed by Samuel Newman
parent 4778142ee8
commit 0d2883ebc1
6 changed files with 324 additions and 6 deletions
@@ -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}
+43 -4
View File
@@ -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,
},
})
})
})
+83
View File
@@ -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')
},
}
}
+16 -1
View File
@@ -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,
)
+55 -1
View File
@@ -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,