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,