Add video upload telemetry events (#10991)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
import {type Platform} from 'react-native'
|
import {type Platform} from 'react-native'
|
||||||
|
|
||||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
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 NotificationType} from '#/state/queries/notifications/types'
|
||||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||||
@@ -1331,4 +1332,99 @@ export type Events = {
|
|||||||
'invite:followersPromo:press': {}
|
'invite:followersPromo:press': {}
|
||||||
// user dismissed the empty-followers promo banner
|
// user dismissed the empty-followers promo banner
|
||||||
'invite:followersPromo:dismiss': {}
|
'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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
|||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo} from './types'
|
||||||
import {extToMime} from './util'
|
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(
|
export async function compressVideo(
|
||||||
file: ImagePickerAsset,
|
file: ImagePickerAsset,
|
||||||
@@ -16,20 +16,36 @@ export async function compressVideo(
|
|||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
|
||||||
file.mimeType as SupportedMimeTypes,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (file.mimeType === 'image/gif') {
|
if (file.mimeType === 'image/gif') {
|
||||||
// let's hope they're small enough that they don't need compression!
|
// let's hope they're small enough that they don't need compression!
|
||||||
// this compression library doesn't support gifs
|
// this compression library doesn't support gifs
|
||||||
// worst case - server rejects them. I think that's fine -sfn
|
// 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
|
// Pre-check the threshold ourselves so we can label the skip in telemetry.
|
||||||
? MIN_SIZE_FOR_COMPRESSION
|
// rnc would do the same skip internally via minimumFileSizeForCompress, but
|
||||||
: 0
|
// 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(
|
const compressed = await Video.compress(
|
||||||
file.uri,
|
file.uri,
|
||||||
@@ -37,8 +53,13 @@ export async function compressVideo(
|
|||||||
compressionMethod: 'manual',
|
compressionMethod: 'manual',
|
||||||
bitrate: 3_000_000, // 3mbps
|
bitrate: 3_000_000, // 3mbps
|
||||||
maxSize: 1920,
|
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
|
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
|
||||||
minimumFileSizeForCompress,
|
minimumFileSizeForCompress: 0,
|
||||||
getCancellationId: id => {
|
getCancellationId: id => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener('abort', () => {
|
signal.addEventListener('abort', () => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export async function compressVideo(
|
|||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|
||||||
const isGif = blob.type === 'image/gif'
|
const isGif = blob.type === 'image/gif'
|
||||||
|
const hasCodecs = hasWebCodecs()
|
||||||
|
|
||||||
logger.debug('compress: fetched blob', {
|
logger.debug('compress: fetched blob', {
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
@@ -57,22 +58,32 @@ export async function compressVideo(
|
|||||||
|
|
||||||
// Try MediaBunny compression if WebCodecs is available and file is large enough
|
// Try MediaBunny compression if WebCodecs is available and file is large enough
|
||||||
// Skip GIFs - MediaBunny doesn't support them
|
// 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 {
|
try {
|
||||||
return await doCompression(blob, asset.uri, {onProgress, signal})
|
return await doCompression(blob, asset.uri, {onProgress, signal})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('compress: MediaBunny compression failed, using original', {
|
logger.warn('compress: MediaBunny compression failed, using original', {
|
||||||
safeMessage: e,
|
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
|
// No compression path - just return the blob as-is
|
||||||
if (blob.size > VIDEO_MAX_SIZE) {
|
if (blob.size > VIDEO_MAX_SIZE) {
|
||||||
throw new VideoTooLargeError()
|
throw new VideoTooLargeError()
|
||||||
@@ -83,6 +94,7 @@ export async function compressVideo(
|
|||||||
size: blob.size,
|
size: blob.size,
|
||||||
bytes: await blob.arrayBuffer(),
|
bytes: await blob.arrayBuffer(),
|
||||||
mimeType: blob.type || 'video/mp4',
|
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 = {
|
export type CompressedVideo = {
|
||||||
uri: string
|
uri: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
size: number
|
size: number
|
||||||
// web only, can fall back to uri if missing
|
// web only, can fall back to uri if missing
|
||||||
bytes?: ArrayBuffer
|
bytes?: ArrayBuffer
|
||||||
|
// Set when the engine returned the input unchanged. Undefined means the
|
||||||
|
// bytes were actually re-encoded.
|
||||||
|
passthroughReason?: VideoCompressSkipReason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
import {useCallOnce} from '#/lib/once'
|
import {useCallOnce} from '#/lib/once'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
@@ -387,6 +388,12 @@ export const ComposePost = ({
|
|||||||
const selectVideo = useCallback(
|
const selectVideo = useCallback(
|
||||||
(postId: string, asset: ImagePickerAsset) => {
|
(postId: string, asset: ImagePickerAsset) => {
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
const telemetry = createVideoTelemetry({
|
||||||
|
asset,
|
||||||
|
signal: abortController.signal,
|
||||||
|
metric: ax.metric,
|
||||||
|
})
|
||||||
|
telemetry.picked()
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'update_post',
|
type: 'update_post',
|
||||||
postId: postId,
|
postId: postId,
|
||||||
@@ -394,6 +401,7 @@ export const ComposePost = ({
|
|||||||
type: 'embed_add_video',
|
type: 'embed_add_video',
|
||||||
asset,
|
asset,
|
||||||
abortController,
|
abortController,
|
||||||
|
telemetry,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
void processVideo(
|
void processVideo(
|
||||||
@@ -412,9 +420,10 @@ export const ComposePost = ({
|
|||||||
currentDid,
|
currentDid,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
i18n,
|
i18n,
|
||||||
|
telemetry,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[i18n, agent, currentDid, composerDispatch],
|
[i18n, agent, currentDid, composerDispatch, ax.metric],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onInitVideo = useNonReactiveCallback(() => {
|
const onInitVideo = useNonReactiveCallback(() => {
|
||||||
@@ -494,6 +503,12 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Start video processing using existing flow
|
// Start video processing using existing flow
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
const telemetry = createVideoTelemetry({
|
||||||
|
asset,
|
||||||
|
signal: abortController.signal,
|
||||||
|
metric: ax.metric,
|
||||||
|
})
|
||||||
|
telemetry.picked()
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'update_post',
|
type: 'update_post',
|
||||||
postId,
|
postId,
|
||||||
@@ -501,6 +516,7 @@ export const ComposePost = ({
|
|||||||
type: 'embed_add_video',
|
type: 'embed_add_video',
|
||||||
asset,
|
asset,
|
||||||
abortController,
|
abortController,
|
||||||
|
telemetry,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -559,6 +575,7 @@ export const ComposePost = ({
|
|||||||
currentDid,
|
currentDid,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
i18n,
|
i18n,
|
||||||
|
telemetry,
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to restore video from draft', {
|
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(
|
const handleSelectDraft = useCallback(
|
||||||
@@ -979,6 +996,15 @@ export const ComposePost = ({
|
|||||||
})
|
})
|
||||||
).uris[0]
|
).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
|
* Wait for app view to have received the post(s). If this fails, it's
|
||||||
* ok, because the post _was_ actually published above.
|
* ok, because the post _was_ actually published above.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {type SelfLabel} from '#/lib/moderation'
|
import {type SelfLabel} from '#/lib/moderation'
|
||||||
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
@@ -88,6 +89,7 @@ export type PostAction =
|
|||||||
type: 'embed_add_video'
|
type: 'embed_add_video'
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
|
telemetry: VideoTelemetry
|
||||||
}
|
}
|
||||||
| {type: 'embed_remove_video'}
|
| {type: 'embed_remove_video'}
|
||||||
| {type: 'embed_update_video'; videoAction: VideoAction}
|
| {type: 'embed_update_video'; videoAction: VideoAction}
|
||||||
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
if (!prevMedia) {
|
if (!prevMedia) {
|
||||||
nextMedia = {
|
nextMedia = {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
video: createVideoState(action.asset, action.abortController),
|
video: createVideoState(
|
||||||
|
action.asset,
|
||||||
|
action.abortController,
|
||||||
|
action.telemetry,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UploadLimitError,
|
UploadLimitError,
|
||||||
VideoTooLargeError,
|
VideoTooLargeError,
|
||||||
} from '#/lib/media/video/errors'
|
} from '#/lib/media/video/errors'
|
||||||
|
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {uploadVideo} from '#/lib/media/video/upload'
|
import {uploadVideo} from '#/lib/media/video/upload'
|
||||||
import {createVideoAgent} from '#/lib/media/video/util'
|
import {createVideoAgent} from '#/lib/media/video/util'
|
||||||
@@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
|
|||||||
video: undefined,
|
video: undefined,
|
||||||
jobId: undefined,
|
jobId: undefined,
|
||||||
pendingPublish: undefined,
|
pendingPublish: undefined,
|
||||||
|
telemetry: undefined,
|
||||||
altText: '',
|
altText: '',
|
||||||
captions: [],
|
captions: [],
|
||||||
})
|
})
|
||||||
@@ -79,6 +81,7 @@ type ErrorState = {
|
|||||||
jobId: string | null
|
jobId: string | null
|
||||||
error: string
|
error: string
|
||||||
pendingPublish?: undefined
|
pendingPublish?: undefined
|
||||||
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
captions: CaptionsTrack[]
|
captions: CaptionsTrack[]
|
||||||
}
|
}
|
||||||
@@ -91,6 +94,7 @@ type CompressingState = {
|
|||||||
video?: undefined
|
video?: undefined
|
||||||
jobId?: undefined
|
jobId?: undefined
|
||||||
pendingPublish?: undefined
|
pendingPublish?: undefined
|
||||||
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
captions: CaptionsTrack[]
|
captions: CaptionsTrack[]
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,7 @@ type UploadingState = {
|
|||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
jobId?: undefined
|
jobId?: undefined
|
||||||
pendingPublish?: undefined
|
pendingPublish?: undefined
|
||||||
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
captions: CaptionsTrack[]
|
captions: CaptionsTrack[]
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,7 @@ type ProcessingState = {
|
|||||||
jobId: string
|
jobId: string
|
||||||
jobStatus: AppBskyVideoDefs.JobStatus | null
|
jobStatus: AppBskyVideoDefs.JobStatus | null
|
||||||
pendingPublish?: undefined
|
pendingPublish?: undefined
|
||||||
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
captions: CaptionsTrack[]
|
captions: CaptionsTrack[]
|
||||||
}
|
}
|
||||||
@@ -128,6 +134,7 @@ type DoneState = {
|
|||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
jobId?: undefined
|
jobId?: undefined
|
||||||
pendingPublish: {blobRef: BlobRef}
|
pendingPublish: {blobRef: BlobRef}
|
||||||
|
telemetry: VideoTelemetry
|
||||||
altText: string
|
altText: string
|
||||||
captions: CaptionsTrack[]
|
captions: CaptionsTrack[]
|
||||||
}
|
}
|
||||||
@@ -142,12 +149,14 @@ export type VideoState =
|
|||||||
export function createVideoState(
|
export function createVideoState(
|
||||||
asset: ImagePickerAsset,
|
asset: ImagePickerAsset,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
|
telemetry: VideoTelemetry,
|
||||||
): CompressingState {
|
): CompressingState {
|
||||||
return {
|
return {
|
||||||
status: 'compressing',
|
status: 'compressing',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
abortController,
|
abortController,
|
||||||
asset,
|
asset,
|
||||||
|
telemetry,
|
||||||
altText: '',
|
altText: '',
|
||||||
captions: [],
|
captions: [],
|
||||||
}
|
}
|
||||||
@@ -170,6 +179,7 @@ export function videoReducer(
|
|||||||
asset: state.asset ?? null,
|
asset: state.asset ?? null,
|
||||||
video: state.video ?? null,
|
video: state.video ?? null,
|
||||||
jobId: state.jobId ?? null,
|
jobId: state.jobId ?? null,
|
||||||
|
telemetry: state.telemetry,
|
||||||
altText: state.altText,
|
altText: state.altText,
|
||||||
captions: state.captions,
|
captions: state.captions,
|
||||||
}
|
}
|
||||||
@@ -198,6 +208,7 @@ export function videoReducer(
|
|||||||
abortController: state.abortController,
|
abortController: state.abortController,
|
||||||
asset: state.asset,
|
asset: state.asset,
|
||||||
video: action.video,
|
video: action.video,
|
||||||
|
telemetry: state.telemetry,
|
||||||
altText: state.altText,
|
altText: state.altText,
|
||||||
captions: state.captions,
|
captions: state.captions,
|
||||||
}
|
}
|
||||||
@@ -213,6 +224,7 @@ export function videoReducer(
|
|||||||
video: state.video,
|
video: state.video,
|
||||||
jobId: action.jobId,
|
jobId: action.jobId,
|
||||||
jobStatus: null,
|
jobStatus: null,
|
||||||
|
telemetry: state.telemetry,
|
||||||
altText: state.altText,
|
altText: state.altText,
|
||||||
captions: state.captions,
|
captions: state.captions,
|
||||||
}
|
}
|
||||||
@@ -239,6 +251,7 @@ export function videoReducer(
|
|||||||
pendingPublish: {
|
pendingPublish: {
|
||||||
blobRef: action.blobRef,
|
blobRef: action.blobRef,
|
||||||
},
|
},
|
||||||
|
telemetry: state.telemetry,
|
||||||
altText: state.altText,
|
altText: state.altText,
|
||||||
captions: state.captions,
|
captions: state.captions,
|
||||||
}
|
}
|
||||||
@@ -265,9 +278,11 @@ export async function processVideo(
|
|||||||
did: string,
|
did: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
|
telemetry: VideoTelemetry,
|
||||||
) {
|
) {
|
||||||
let video: CompressedVideo | undefined
|
let video: CompressedVideo | undefined
|
||||||
try {
|
try {
|
||||||
|
telemetry.compressStarted()
|
||||||
video = await compressVideo(asset, {
|
video = await compressVideo(asset, {
|
||||||
onProgress: num => {
|
onProgress: num => {
|
||||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||||
@@ -277,6 +292,7 @@ export async function processVideo(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = getCompressErrorMessage(e, i18n)
|
const message = getCompressErrorMessage(e, i18n)
|
||||||
if (message !== null) {
|
if (message !== null) {
|
||||||
|
telemetry.compressFailed(e)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'to_error',
|
type: 'to_error',
|
||||||
error: message,
|
error: message,
|
||||||
@@ -285,6 +301,15 @@ export async function processVideo(
|
|||||||
}
|
}
|
||||||
return
|
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({
|
dispatch({
|
||||||
type: 'compressing_to_uploading',
|
type: 'compressing_to_uploading',
|
||||||
video,
|
video,
|
||||||
@@ -293,6 +318,7 @@ export async function processVideo(
|
|||||||
|
|
||||||
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
||||||
try {
|
try {
|
||||||
|
telemetry.uploadStarted(video.size)
|
||||||
uploadResponse = await uploadVideo({
|
uploadResponse = await uploadVideo({
|
||||||
video,
|
video,
|
||||||
agent,
|
agent,
|
||||||
@@ -306,6 +332,7 @@ export async function processVideo(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = getUploadErrorMessage(e, i18n)
|
const message = getUploadErrorMessage(e, i18n)
|
||||||
if (message !== null) {
|
if (message !== null) {
|
||||||
|
telemetry.uploadFailed(e)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'to_error',
|
type: 'to_error',
|
||||||
error: message,
|
error: message,
|
||||||
@@ -316,6 +343,8 @@ export async function processVideo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jobId = uploadResponse.jobId
|
const jobId = uploadResponse.jobId
|
||||||
|
telemetry.uploadCompleted(jobId)
|
||||||
|
telemetry.processingStarted(jobId)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'uploading_to_processing',
|
type: 'uploading_to_processing',
|
||||||
jobId,
|
jobId,
|
||||||
@@ -354,6 +383,7 @@ export async function processVideo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error processing video', {safeMessage: e})
|
logger.error('Error processing video', {safeMessage: e})
|
||||||
|
telemetry.processingFailed(e)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'to_error',
|
type: 'to_error',
|
||||||
error: i18n._(msg`Video failed to process`),
|
error: i18n._(msg`Video failed to process`),
|
||||||
@@ -363,6 +393,7 @@ export async function processVideo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
|
telemetry.processingCompleted()
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'to_done',
|
type: 'to_done',
|
||||||
blobRef: blob,
|
blobRef: blob,
|
||||||
|
|||||||
Reference in New Issue
Block a user