diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index f38677ea1d..bead58fd91 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -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(null) @@ -81,6 +87,7 @@ export function VideoEmbedInnerNative({ setTimeRemaining(e.nativeEvent.timeRemaining) }} onError={e => { + onError?.(e.nativeEvent.error) setError(e.nativeEvent.error) }} ref={videoRef} diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index afe1bcedb6..66693e780c 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -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(null) + useEffect(() => { + return () => { + telemetryRef.current?.deactivated() + } + }, []) + const showOverlay = !isActive || isLoading || @@ -89,9 +104,33 @@ function InnerWrapper({embed}: Props) { <> { + 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} /> ({ + 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, + }, + }) + }) +}) diff --git a/src/lib/media/video/playbackTelemetry.ts b/src/lib/media/video/playbackTelemetry.ts new file mode 100644 index 0000000000..d554619b85 --- /dev/null +++ b/src/lib/media/video/playbackTelemetry.ts @@ -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 | 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') + }, + } +} diff --git a/src/logger/sentry/setup/index.ts b/src/logger/sentry/setup/index.ts index 666fc3b0a6..d4607e08a1 100644 --- a/src/logger/sentry/setup/index.ts +++ b/src/logger/sentry/setup/index.ts @@ -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, +) diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 7e760b88df..99dc03d208 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -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 = ({ <> {shouldRenderVideo && player && ( - + )} {moderation && ( { 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(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,