8171414f8a
Multipart is now the only upload transport, so drop the legacy `app.bsky.video.uploadVideo` request and everything that existed to choose between the two: - `MultipartFallbackError` is gone. A `startUpload` failure (a service without multipart support, or with the kill switch active) now propagates as the `MultipartUploadError` the API layer already built, and `abortThenRethrowOrResolve` rethrows the failure that forced the abort instead of a fallback signal - which keeps the real error class for retry classification and telemetry. - `getUploadErrorMessage` matches on `MultipartUploadError` where it matched on `ServerError`, so video-service messages (upload disabled, daily limits, oversized file) still map to their user-facing copy. `ServerError` had no other thrower and is removed. - With the legacy branch gone, `upload.ts` and `upload.web.ts` were identical, so the platform split collapses into one `upload.ts`. The web chunk reader already fetches the uri when `bytes` is missing, so nothing depended on the legacy path's buffer fetch. - The `did` parameter only seeded the legacy endpoint URL; it is dropped from `uploadVideo` and `processVideo`. - Transport reporting only existed to distinguish multipart from legacy, so `VideoUploadTransport`, `telemetry.uploadTransport`, the `video.upload.transport` span attribute, and the `transport` field on the uploadCompleted/uploadFailed events are removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mn8DoxP5wQk6NbPmapWs9A
300 lines
7.6 KiB
TypeScript
300 lines
7.6 KiB
TypeScript
import {Platform} from 'react-native'
|
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
|
import {nanoid} from 'nanoid/non-secure'
|
|
|
|
import {
|
|
type ProbedMetadata,
|
|
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:@bsky.app/video-compressor@0.2.0'
|
|
|
|
type Phase = 'compress' | 'upload' | 'processing'
|
|
|
|
function errorClass(e: unknown): string {
|
|
if (e instanceof Error) return e.name || 'Error'
|
|
return 'Unknown'
|
|
}
|
|
|
|
function errorMessage(e: unknown): string {
|
|
const message = e instanceof Error ? e.message : String(e)
|
|
return message.slice(0, 256)
|
|
}
|
|
|
|
export type VideoTelemetry = {
|
|
readonly uploadId: string
|
|
readonly engine: string
|
|
picked: () => void
|
|
compressStarted: () => void
|
|
probed: (metadata: ProbedMetadata) => 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,
|
|
})
|
|
},
|
|
|
|
probed(metadata) {
|
|
metric('video:upload:probed', {
|
|
uploadId,
|
|
engine,
|
|
mimeType: metadata.mimeType,
|
|
codec: metadata.codec,
|
|
width: metadata.width,
|
|
height: metadata.height,
|
|
duration: metadata.duration,
|
|
bitrate: metadata.bitrate,
|
|
fileSize: metadata.fileSize,
|
|
hasAudio: metadata.hasAudio,
|
|
frameRate: metadata.frameRate,
|
|
rotation: metadata.rotation,
|
|
isHDR: metadata.isHDR,
|
|
})
|
|
},
|
|
|
|
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),
|
|
errorMessage: errorMessage(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,
|
|
})
|
|
},
|
|
}
|
|
}
|