Add video playback observability to Sentry (#11111)
This commit is contained in:
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user