Add video upload telemetry events (APP-2458)

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.

Implements the Metrics workstream from the Video v2 frontend spec (§D):
fleet visibility into per-phase timings, codec paths, file-size
distributions, and abandonment phase. 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.
This commit is contained in:
vineyardbovines
2026-06-23 09:34:40 -04:00
parent f40ff6250a
commit a289bc153c
8 changed files with 461 additions and 4 deletions
+95
View File
@@ -1331,4 +1331,99 @@ export type Events = {
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
// === Video upload funnel (APP-2458, Frontend Spec §D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g. native:rnc@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' | 'web-passthrough' | 'below-threshold' | 'probe-failed'
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
}
}
+8 -1
View File
@@ -22,7 +22,12 @@ export async function compressVideo(
},
): Promise<CompressedVideo> {
if (file.mimeType === 'image/gif') {
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 isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
@@ -40,6 +45,7 @@ export async function compressVideo(
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'probe-failed',
}
}
@@ -48,6 +54,7 @@ export async function compressVideo(
uri: file.uri,
size: metadata.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'below-threshold',
}
}
+2
View File
@@ -25,6 +25,8 @@ export async function compressVideo(
uri,
bytes: await blob.arrayBuffer(),
mimeType,
// web today is pure pass-through (no WebCodecs); see frontend spec §A1.
passthroughReason: 'web-passthrough',
}
}
+282
View File
@@ -0,0 +1,282 @@
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;
// web flips off pass-through when A1.1 (mediabunny / WebCodecs) lands.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:passthrough'
: 'native:expo-bluesky-video-compress'
type Phase = 'compress' | 'upload' | 'processing'
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'
}
type SkipReason =
| 'gif'
| 'web-passthrough'
| 'below-threshold'
| 'probe-failed'
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'
| 'probe-failed'
| 'below-threshold'
| 'web-passthrough'
}
+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 guard above (status !== 'done') 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,