Add video upload telemetry events (#10991)
This commit is contained in:
@@ -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<CompressedVideo> {
|
||||
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', () => {
|
||||
|
||||
@@ -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<CompressedVideo['passthroughReason']> | 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = <E extends keyof Metrics>(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<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, 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user