diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index a9d7f7bb17..e34feae3a5 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -5,6 +5,7 @@ import {type Platform} from 'react-native' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' +import {type VideoCompressSkipReason} from '#/lib/media/video/types' import {type NotificationType} from '#/state/queries/notifications/types' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' @@ -1331,4 +1332,99 @@ 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 + skipReason: VideoCompressSkipReason + 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 1d00bfcea1..dfb2e17b4b 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -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 { 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-byte-threshold', + } + } const compressed = await Video.compress( file.uri, @@ -37,8 +53,13 @@ export async function compressVideo( compressionMethod: 'manual', bitrate: 3_000_000, // 3mbps maxSize: 1920, + // Force a transcode for unacceptable-format files regardless of size. + // rnc's default minimumFileSizeForCompress would otherwise pass small + // unacceptable-format files through unchanged and the server would + // reject them. Acceptable formats are already short-circuited above so + // they never reach this call. // WARNING: this ONE SPECIFIC ARG is in MB -sfn - minimumFileSizeForCompress, + minimumFileSizeForCompress: 0, getCancellationId: id => { if (signal) { signal.addEventListener('abort', () => { diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 96e0fc43f0..62e8469351 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -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 | null = + null + if (isGif) { + fallbackReason = 'gif' + } else if (!hasCodecs) { + fallbackReason = 'no-webcodecs' + } else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) { + fallbackReason = 'below-byte-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, } } diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts new file mode 100644 index 0000000000..7ef5a874ef --- /dev/null +++ b/src/lib/media/video/telemetry.ts @@ -0,0 +1,271 @@ +import {Platform} from 'react-native' +import {type ImagePickerAsset} from 'expo-image-picker' +import {nanoid} from 'nanoid/non-secure' + +import {type VideoCompressSkipReason} from '#/lib/media/video/types' +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. +// 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' + +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 + skipReason: VideoCompressSkipReason + }) => 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 = nanoid() + 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, skipReason}) { + metric('video:upload:compressSkipped', { + uploadId, + engine, + skipReason, + 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..d22832794a 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -1,7 +1,20 @@ +// Why the compress engine returned the input unchanged. Used both as the +// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field +// on the `video:upload:compressSkipped` analytics event, so the two stay in +// sync. +export type VideoCompressSkipReason = + | 'gif' + | 'below-byte-threshold' + | 'no-webcodecs' + | 'compress-error-fallback' + export type CompressedVideo = { uri: string mimeType: string 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. + passthroughReason?: VideoCompressSkipReason } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 78a78a22a8..92b21ac52c 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 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. 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..6737d1f628 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, + skipReason: 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,