From d7d88421714de6052b2fa843733302067a200eb4 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 25 Jun 2026 16:44:04 -0400 Subject: [PATCH] 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. --- src/analytics/metrics/types.ts | 99 +++++++++ src/lib/media/video/compress.ts | 36 ++- src/lib/media/video/compress.web.ts | 26 ++- src/lib/media/video/telemetry.ts | 283 ++++++++++++++++++++++++ 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, 500 insertions(+), 21 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 a9d7f7bb17..bdd23d6fba 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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 + } } diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 1d00bfcea1..2b05940983 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-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', () => { diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 96e0fc43f0..83d8fc4e5b 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-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..068800ec83 --- /dev/null +++ b/src/lib/media/video/telemetry.ts @@ -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 = (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 | 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..69175ea48c 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' + | 'below-threshold' + | 'no-webcodecs' + | 'compress-error-fallback' } 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..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,