From a289bc153c56fe17d0be6858572146f11d7fbf93 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 23 Jun 2026 09:34:40 -0400 Subject: [PATCH] Add video upload telemetry events (APP-2458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/analytics/metrics/types.ts | 95 ++++++++ src/lib/media/video/compress.ts | 9 +- src/lib/media/video/compress.web.ts | 2 + src/lib/media/video/telemetry.ts | 282 ++++++++++++++++++++++++ src/lib/media/video/types.ts | 8 + src/view/com/composer/Composer.tsx | 30 ++- src/view/com/composer/state/composer.ts | 8 +- src/view/com/composer/state/video.ts | 31 +++ 8 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 src/lib/media/video/telemetry.ts diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 7fba1b9555..5ff1212a59 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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 + } } diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 826419e789..cb5a1a9d1b 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -22,7 +22,12 @@ export async function compressVideo( }, ): Promise { 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', } } diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 83fdfde533..67993ac184 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -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', } } diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts new file mode 100644 index 0000000000..88c50b21d5 --- /dev/null +++ b/src/lib/media/video/telemetry.ts @@ -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 = (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 | 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, + }) + }, + } +} diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index ae873d7565..445eb21c46 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -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' } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 54932286be..7f86e9413a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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. diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 35e1706007..fc8b341270 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -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 { diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 54bfcd67af..c719864178 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -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,