Add video upload telemetry events

Instruments the video upload pipeline with a typed event family
(picked, compress{Started,Completed,Skipped,Failed}, upload{Started,
Completed,Failed}, processing{Started,Completed,Failed}, published,
abandoned) plus a parent Sentry span with child spans per phase. Every
event carries an uploadId, engine identifier, and jobId once known.

Fleet visibility into per-phase timings, codec paths, file-size
distributions, and the phase users bail in. No content is captured -
sizes, codecs, dimensions, timings, and error class names only.

Telemetry is created alongside the abort controller in Composer.tsx
and threaded through the composer reducer onto VideoState so
onPressPublish can fire the published event once apilib.post resolves.
The abort signal drives the abandoned event with the phase the user
bailed in.

Engine label distinguishes compression backends - currently
react-native-compressor on native and mediabunny on web - so the
analytics can split metrics by engine when the native rewrite ships.
This commit is contained in:
vineyardbovines
2026-06-25 16:44:04 -04:00
parent 46f5437faa
commit d7d8842171
8 changed files with 500 additions and 21 deletions
+99
View File
@@ -1331,4 +1331,103 @@ export type Events = {
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
// native:react-native-compressor@1.13.0). jobId is added once the server
// returns it. Sizes / codecs / dimensions / timings only - never content.
'video:upload:picked': {
uploadId: string
engine: string
sourceMimeType?: string
sourceBytes?: number
sourceDurationMs?: number
sourceWidth?: number
sourceHeight?: number
}
'video:upload:compressStarted': {
uploadId: string
engine: string
sourceBytes?: number
}
'video:upload:compressCompleted': {
uploadId: string
engine: string
bytesIn?: number
bytesOut: number
outputMimeType: string
elapsedMs: number
}
'video:upload:compressSkipped': {
uploadId: string
engine: string
reason:
| 'gif'
| 'below-threshold'
| 'no-webcodecs'
| 'compress-error-fallback'
bytes: number
mimeType: string
elapsedMs: number
}
'video:upload:compressFailed': {
uploadId: string
engine: string
errorClass: string
elapsedMs: number
}
'video:upload:uploadStarted': {
uploadId: string
engine: string
bytes: number
}
'video:upload:uploadCompleted': {
uploadId: string
engine: string
jobId: string
bytes: number
elapsedMs: number
throughputBytesPerSec: number
}
'video:upload:uploadFailed': {
uploadId: string
engine: string
bytes: number
errorClass: string
elapsedMs: number
}
'video:upload:processingStarted': {
uploadId: string
engine: string
jobId: string
}
'video:upload:processingCompleted': {
uploadId: string
engine: string
jobId: string
elapsedMs: number
}
'video:upload:processingFailed': {
uploadId: string
engine: string
jobId: string
errorClass: string
elapsedMs: number
}
'video:upload:published': {
uploadId: string
engine: string
jobId: string
// wall-clock from picked to published
totalElapsedMs: number
}
// The event that measures the actual problem: users giving up mid-wait.
'video:upload:abandoned': {
uploadId: string
engine: string
phase: 'compress' | 'upload' | 'processing'
jobId?: string
elapsedInPhaseMs: number
}
}
+25 -11
View File
@@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
import {type CompressedVideo} from './types'
import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
export async function compressVideo(
file: ImagePickerAsset,
@@ -16,20 +16,36 @@ export async function compressVideo(
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (file.mimeType === 'image/gif') {
// let's hope they're small enough that they don't need compression!
// this compression library doesn't support gifs
// worst case - server rejects them. I think that's fine -sfn
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
return {
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: 'image/gif',
passthroughReason: 'gif',
}
}
const minimumFileSizeForCompress = isAcceptableFormat
? MIN_SIZE_FOR_COMPRESSION
: 0
// Pre-check the threshold ourselves so we can label the skip in telemetry.
// rnc would do the same skip internally via minimumFileSizeForCompress, but
// that path is invisible to us.
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (
isAcceptableFormat &&
file.fileSize != null &&
file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES
) {
return {
uri: file.uri,
size: file.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'below-threshold',
}
}
const compressed = await Video.compress(
file.uri,
@@ -37,8 +53,6 @@ export async function compressVideo(
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
+19 -7
View File
@@ -47,6 +47,7 @@ export async function compressVideo(
const blob = await response.blob()
const isGif = blob.type === 'image/gif'
const hasCodecs = hasWebCodecs()
logger.debug('compress: fetched blob', {
size: blob.size,
@@ -57,22 +58,32 @@ export async function compressVideo(
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) {
let fallbackReason: NonNullable<CompressedVideo['passthroughReason']> | null =
null
if (isGif) {
fallbackReason = 'gif'
} else if (!hasCodecs) {
fallbackReason = 'no-webcodecs'
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
fallbackReason = 'below-threshold'
} else {
try {
return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e,
})
fallbackReason = 'compress-error-fallback'
}
} else {
logger.debug('compress: skipping compression', {
hasWebCodecs: hasWebCodecs(),
blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES,
})
}
logger.debug('compress: skipping compression', {
hasWebCodecs: hasCodecs,
blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES,
reason: fallbackReason,
})
// No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
@@ -83,6 +94,7 @@ export async function compressVideo(
size: blob.size,
bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4',
passthroughReason: fallbackReason,
}
}
+283
View File
@@ -0,0 +1,283 @@
import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
// Identifies the active compression engine. Bumped when the engine swaps.
// Versions are intentionally hardcoded so a dependency bump shows up in
// analytics as a label change.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:mediabunny@1.25.3'
: 'native:react-native-compressor@1.13.0'
type Phase = 'compress' | 'upload' | 'processing'
type SkipReason =
| 'gif'
| 'below-threshold'
| 'no-webcodecs'
| 'compress-error-fallback'
function makeUploadId(): string {
const c = (globalThis as {crypto?: {randomUUID?: () => string}}).crypto
if (c?.randomUUID) return c.randomUUID()
return `up_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 10)}`
}
function errorClass(e: unknown): string {
if (e instanceof Error) return e.name || 'Error'
return 'Unknown'
}
export type VideoTelemetry = {
readonly uploadId: string
readonly engine: string
picked: () => void
compressStarted: () => void
compressSkipped: (video: {
size: number
mimeType: string
reason: SkipReason
}) => void
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
processingCompleted: () => void
processingFailed: (e: unknown) => void
published: () => void
}
export function createVideoTelemetry({
asset,
signal,
metric,
}: {
asset: ImagePickerAsset
signal: AbortSignal
metric: MetricFn
}): VideoTelemetry {
const uploadId = makeUploadId()
const engine = COMPRESS_ENGINE
const startedAt = Date.now()
let phase: Phase | undefined
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let txnEnded = false
let abortBound = true
// Parent span: full selection->ready arc. Inactive so phase spans can be
// attached as children regardless of the current async context.
const txn = Sentry.startInactiveSpan({
name: 'video.upload',
op: 'video.upload',
attributes: {
uploadId,
engine,
'video.source.mime': asset.mimeType ?? 'unknown',
'video.source.bytes': asset.fileSize ?? 0,
'video.source.durationMs': asset.duration ?? 0,
'video.source.width': asset.width ?? 0,
'video.source.height': asset.height ?? 0,
},
})
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
function endPhaseSpan() {
if (!phaseSpan) return
phaseSpan.end()
phaseSpan = undefined
}
function enterPhase(next: Phase, spanName: string) {
endPhaseSpan()
phase = next
phaseStartedAt = Date.now()
phaseSpan = Sentry.withActiveSpan(txn, () =>
Sentry.startInactiveSpan({
name: spanName,
op: spanName,
attributes: {uploadId, engine},
}),
)
}
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
if (txnEnded) return
txnEnded = true
endPhaseSpan()
txn.setAttribute('outcome', outcome)
txn.end()
}
function detachAbort() {
if (!abortBound) return
abortBound = false
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
if (phase) {
metric('video:upload:abandoned', {
uploadId,
engine,
phase,
jobId,
elapsedInPhaseMs: Date.now() - phaseStartedAt,
})
}
endTxn('cancelled')
abortBound = false
}
signal.addEventListener('abort', onAbort, {once: true})
return {
uploadId,
engine,
picked() {
metric('video:upload:picked', {
uploadId,
engine,
sourceMimeType: asset.mimeType,
sourceBytes: asset.fileSize,
sourceDurationMs: asset.duration ?? undefined,
sourceWidth: asset.width,
sourceHeight: asset.height,
})
},
compressStarted() {
enterPhase('compress', 'video.compress')
metric('video:upload:compressStarted', {
uploadId,
engine,
sourceBytes: asset.fileSize,
})
},
compressSkipped({size, mimeType, reason}) {
metric('video:upload:compressSkipped', {
uploadId,
engine,
reason,
bytes: size,
mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressCompleted({size, mimeType}) {
metric('video:upload:compressCompleted', {
uploadId,
engine,
bytesIn: asset.fileSize,
bytesOut: size,
outputMimeType: mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressFailed(e) {
metric('video:upload:compressFailed', {
uploadId,
engine,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
uploadStarted(bytes) {
uploadBytes = bytes
enterPhase('upload', 'video.upload.transfer')
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
const bytes = uploadBytes ?? 0
metric('video:upload:uploadCompleted', {
uploadId,
engine,
jobId: id,
bytes,
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
})
endPhaseSpan()
phase = undefined
},
uploadFailed(e) {
metric('video:upload:uploadFailed', {
uploadId,
engine,
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
processingStarted(id) {
jobId = id
enterPhase('processing', 'video.processing')
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
},
processingCompleted() {
metric('video:upload:processingCompleted', {
uploadId,
engine,
jobId: jobId ?? '',
elapsedMs: Date.now() - phaseStartedAt,
})
// Upload pipeline is done; publish is a separate user action that
// fires its own event. Releases the parent span so its duration
// measures upload work, not idle composer time.
endTxn('ok')
detachAbort()
},
processingFailed(e) {
metric('video:upload:processingFailed', {
uploadId,
engine,
jobId: jobId ?? '',
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
published() {
metric('video:upload:published', {
uploadId,
engine,
jobId: jobId ?? '',
totalElapsedMs: Date.now() - startedAt,
})
},
}
}
+8
View File
@@ -4,4 +4,12 @@ export type CompressedVideo = {
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
// Set when the engine returned the input unchanged. Undefined means the
// bytes were actually re-encoded. Used by telemetry to split
// compressCompleted vs compressSkipped, and to label the skip reason.
passthroughReason?:
| 'gif'
| 'below-threshold'
| 'no-webcodecs'
| 'compress-error-fallback'
}
+28 -2
View File
@@ -73,6 +73,7 @@ import {
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
@@ -387,6 +388,12 @@ export const ComposePost = ({
const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => {
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId: postId,
@@ -394,6 +401,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
void processVideo(
@@ -412,9 +420,10 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
telemetry,
)
},
[i18n, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch, ax.metric],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -494,6 +503,12 @@ export const ComposePost = ({
// Start video processing using existing flow
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId,
@@ -501,6 +516,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
@@ -559,6 +575,7 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
telemetry,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
@@ -567,7 +584,7 @@ export const ComposePost = ({
})
}
},
[i18n, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch, ax.metric],
)
const handleSelectDraft = useCallback(
@@ -979,6 +996,15 @@ export const ComposePost = ({
})
).uris[0]
// Fire published event for every video that made it into the post.
// The status guard upstream ensures each video.telemetry is present and
// processing has completed by this point.
for (const post of filteredThread.posts) {
if (post.embed.media?.type === 'video') {
post.embed.media.video.telemetry?.published()
}
}
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
+7 -1
View File
@@ -8,6 +8,7 @@ import {
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type SelfLabel} from '#/lib/moderation'
import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
@@ -88,6 +89,7 @@ export type PostAction =
type: 'embed_add_video'
asset: ImagePickerAsset
abortController: AbortController
telemetry: VideoTelemetry
}
| {type: 'embed_remove_video'}
| {type: 'embed_update_video'; videoAction: VideoAction}
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
if (!prevMedia) {
nextMedia = {
type: 'video',
video: createVideoState(action.asset, action.abortController),
video: createVideoState(
action.asset,
action.abortController,
action.telemetry,
),
}
}
return {
+31
View File
@@ -11,6 +11,7 @@ import {
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util'
@@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
video: undefined,
jobId: undefined,
pendingPublish: undefined,
telemetry: undefined,
altText: '',
captions: [],
})
@@ -79,6 +81,7 @@ type ErrorState = {
jobId: string | null
error: string
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -91,6 +94,7 @@ type CompressingState = {
video?: undefined
jobId?: undefined
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -103,6 +107,7 @@ type UploadingState = {
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -116,6 +121,7 @@ type ProcessingState = {
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -128,6 +134,7 @@ type DoneState = {
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef}
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -142,12 +149,14 @@ export type VideoState =
export function createVideoState(
asset: ImagePickerAsset,
abortController: AbortController,
telemetry: VideoTelemetry,
): CompressingState {
return {
status: 'compressing',
progress: 0,
abortController,
asset,
telemetry,
altText: '',
captions: [],
}
@@ -170,6 +179,7 @@ export function videoReducer(
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -198,6 +208,7 @@ export function videoReducer(
abortController: state.abortController,
asset: state.asset,
video: action.video,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -213,6 +224,7 @@ export function videoReducer(
video: state.video,
jobId: action.jobId,
jobStatus: null,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -239,6 +251,7 @@ export function videoReducer(
pendingPublish: {
blobRef: action.blobRef,
},
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -265,9 +278,11 @@ export async function processVideo(
did: string,
signal: AbortSignal,
i18n: I18n,
telemetry: VideoTelemetry,
) {
let video: CompressedVideo | undefined
try {
telemetry.compressStarted()
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
@@ -277,6 +292,7 @@ export async function processVideo(
} catch (e) {
const message = getCompressErrorMessage(e, i18n)
if (message !== null) {
telemetry.compressFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -285,6 +301,15 @@ export async function processVideo(
}
return
}
if (video.passthroughReason) {
telemetry.compressSkipped({
size: video.size,
mimeType: video.mimeType,
reason: video.passthroughReason,
})
} else {
telemetry.compressCompleted({size: video.size, mimeType: video.mimeType})
}
dispatch({
type: 'compressing_to_uploading',
video,
@@ -293,6 +318,7 @@ export async function processVideo(
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try {
telemetry.uploadStarted(video.size)
uploadResponse = await uploadVideo({
video,
agent,
@@ -306,6 +332,7 @@ export async function processVideo(
} catch (e) {
const message = getUploadErrorMessage(e, i18n)
if (message !== null) {
telemetry.uploadFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -316,6 +343,8 @@ export async function processVideo(
}
const jobId = uploadResponse.jobId
telemetry.uploadCompleted(jobId)
telemetry.processingStarted(jobId)
dispatch({
type: 'uploading_to_processing',
jobId,
@@ -354,6 +383,7 @@ export async function processVideo(
}
logger.error('Error processing video', {safeMessage: e})
telemetry.processingFailed(e)
dispatch({
type: 'to_error',
error: i18n._(msg`Video failed to process`),
@@ -363,6 +393,7 @@ export async function processVideo(
}
if (blob) {
telemetry.processingCompleted()
dispatch({
type: 'to_done',
blobRef: blob,