: null,
authorizationHeader: this.authService.getRequestHeaderValue(),
+ metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
+
embedUrl: video.embedUrl,
embedTitle: video.name,
instanceName: this.serverConfig.instance.name,
import './shared/mobile/peertube-mobile-plugin'
import './shared/mobile/peertube-mobile-buttons'
import './shared/hotkeys/peertube-hotkeys-plugin'
+import './shared/metrics/metrics-plugin'
import videojs from 'video.js'
import { logger } from '@root-helpers/logger'
import { PluginsManager } from '@root-helpers/plugins-manager'
const httpStats = data.http
const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed)
- const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed)
+ const uploadSpeed = bytes(p2pStats.uploadSpeed)
const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded)
- const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded)
+ const totalUploaded = bytes(p2pStats.uploaded)
const numPeers = p2pStats.numPeers
subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n'
'isLive',
'videoUUID'
])
+ },
+ metrics: {
+ mode: this.mode,
+
+ ...pick(commonOptions, [
+ 'metricsUrl',
+ 'videoUUID'
+ ])
}
}
--- /dev/null
+export * from './metrics-plugin'
--- /dev/null
+import videojs from 'video.js'
+import { PlaybackMetricCreate } from '../../../../../../shared/models'
+import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types'
+
+const Plugin = videojs.getPlugin('plugin')
+
+class MetricsPlugin extends Plugin {
+ private readonly metricsUrl: string
+ private readonly videoUUID: string
+ private readonly mode: PlayerMode
+
+ private downloadedBytesP2P = 0
+ private downloadedBytesHTTP = 0
+ private uploadedBytesP2P = 0
+
+ private resolutionChanges = 0
+ private errors = 0
+
+ private lastPlayerNetworkInfo: PlayerNetworkInfo
+
+ private metricsInterval: any
+
+ private readonly CONSTANTS = {
+ METRICS_INTERVAL: 15000
+ }
+
+ constructor (player: videojs.Player, options: MetricsPluginOptions) {
+ super(player)
+
+ this.metricsUrl = options.metricsUrl
+ this.videoUUID = options.videoUUID
+ this.mode = options.mode
+
+ this.player.one('play', () => {
+ this.runMetricsInterval()
+
+ this.trackBytes()
+ this.trackResolutionChange()
+ this.trackErrors()
+ })
+ }
+
+ dispose () {
+ if (this.metricsInterval) clearInterval(this.metricsInterval)
+ }
+
+ private runMetricsInterval () {
+ this.metricsInterval = setInterval(() => {
+ let resolution: number
+ let fps: number
+
+ if (this.mode === 'p2p-media-loader') {
+ const level = this.player.p2pMediaLoader().getCurrentLevel()
+ if (!level) return
+
+ resolution = Math.min(level.height || 0, level.width || 0)
+
+ const framerate = level?.attrs['FRAME-RATE']
+ fps = framerate
+ ? parseInt(framerate, 10)
+ : undefined
+ } else { // webtorrent
+ const videoFile = this.player.webtorrent().getCurrentVideoFile()
+ if (!videoFile) return
+
+ resolution = videoFile.resolution.id
+ fps = videoFile.fps
+ }
+
+ const body: PlaybackMetricCreate = {
+ resolution,
+ fps,
+
+ playerMode: this.mode,
+
+ resolutionChanges: this.resolutionChanges,
+
+ errors: this.errors,
+
+ downloadedBytesP2P: this.downloadedBytesP2P,
+ downloadedBytesHTTP: this.downloadedBytesHTTP,
+
+ uploadedBytesP2P: this.uploadedBytesP2P,
+
+ videoId: this.videoUUID
+ }
+
+ this.resolutionChanges = 0
+
+ this.downloadedBytesP2P = 0
+ this.downloadedBytesHTTP = 0
+
+ this.uploadedBytesP2P = 0
+
+ this.errors = 0
+
+ const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
+
+ return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers })
+ }, this.CONSTANTS.METRICS_INTERVAL)
+ }
+
+ private trackBytes () {
+ this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => {
+ this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
+ this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0)
+
+ this.uploadedBytesP2P += data.p2p.uploaded - (this.lastPlayerNetworkInfo?.p2p.uploaded || 0)
+
+ this.lastPlayerNetworkInfo = data
+ })
+ }
+
+ private trackResolutionChange () {
+ this.player.on('engineResolutionChange', () => {
+ this.resolutionChanges++
+ })
+ }
+
+ private trackErrors () {
+ this.player.on('error', () => {
+ this.errors++
+ })
+ }
+}
+
+videojs.registerPlugin('metrics', MetricsPlugin)
+export { MetricsPlugin }
import videojs from 'video.js'
import { Events, Segment } from '@peertube/p2p-media-loader-core'
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
+import { logger } from '@root-helpers/logger'
import { timeToInt } from '@shared/core-utils'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { registerConfigPlugin, registerSourceHandler } from './hls-plugin'
-import { logger } from '@root-helpers/logger'
registerConfigPlugin(videojs)
registerSourceHandler(videojs)
}
private statsHTTPBytes = {
pendingDownload: [] as number[],
- pendingUpload: [] as number[],
- totalDownload: 0,
- totalUpload: 0
+ totalDownload: 0
}
private startTime: number
this.statsP2PBytes.numPeers = 1 + this.options.redundancyUrlManager.countBaseUrls()
this.runStats()
+
+ this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange'))
}
private runStats () {
})
this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => {
- const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
+ if (method !== 'p2p') {
+ logger.error(`Received upload from unknown method ${method}`)
+ return
+ }
- elem.pendingUpload.push(bytes)
- elem.totalUpload += bytes
+ this.statsP2PBytes.pendingUpload.push(bytes)
+ this.statsP2PBytes.totalUpload += bytes
})
this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload)
const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload)
- const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload)
this.statsP2PBytes.pendingDownload = []
this.statsP2PBytes.pendingUpload = []
this.statsHTTPBytes.pendingDownload = []
- this.statsHTTPBytes.pendingUpload = []
return this.player.trigger('p2pInfo', {
source: 'p2p-media-loader',
http: {
downloadSpeed: httpDownloadSpeed,
- uploadSpeed: httpUploadSpeed,
- downloaded: this.statsHTTPBytes.totalDownload,
- uploaded: this.statsHTTPBytes.totalUpload
+ downloaded: this.statsHTTPBytes.totalDownload
},
p2p: {
downloadSpeed: p2pDownloadSpeed,
this.listenFullScreenChange()
}
+ // ---------------------------------------------------------------------------
+
private runUserViewing () {
let lastCurrentTime = this.startTime
let lastViewEvent: VideoViewEvent
return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
}
+ // ---------------------------------------------------------------------------
+
private listenFullScreenChange () {
this.player.on('fullscreenchange', () => {
if (this.player.isFullscreen()) this.player.focus()
const httpStats = data.http
this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ')
- this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed).join(' ')
+ this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed).join(' ')
this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ')
- this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ')
+ this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded).join(' ')
this.playerNetworkInfo.numPeers = p2pStats.numPeers
this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s'
}
this.updateVideoFile(newVideoFile, options)
+
+ this.player.trigger('engineResolutionChange')
}
flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
source: 'webtorrent',
http: {
downloadSpeed: 0,
- uploadSpeed: 0,
- downloaded: 0,
- uploaded: 0
+ downloaded: 0
},
p2p: {
downloadSpeed: this.torrent.downloadSpeed,
videoViewUrl: string
authorizationHeader?: string
+ metricsUrl: string
+
embedUrl: string
embedTitle: string
videoUUID: string
}
+type MetricsPluginOptions = {
+ mode: PlayerMode
+ metricsUrl: string
+ videoUUID: string
+}
+
type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions
+ metrics: MetricsPluginOptions
webtorrent?: WebtorrentPluginOptions
http: {
downloadSpeed: number
- uploadSpeed: number
downloaded: number
- uploaded: number
}
p2p: {
ResolutionUpdateData,
AutoResolutionUpdateData,
PlaylistPluginOptions,
+ MetricsPluginOptions,
VideoJSCaption,
PeerTubePluginOptions,
WebtorrentPluginOptions,
videoCaptions,
inactivityTimeout: 2500,
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
+ metricsUrl: window.location.origin + '/api/v1/metrics/playback',
videoShortUUID: video.shortUUID,
videoUUID: video.uuid,
video_studio:
enabled: true
+
+open_telemetry:
+ metrics:
+ prometheus_exporter:
+ port: 9092
"@babel/parser": "^7.17.8",
"@node-oauth/oauth2-server": "^4.2.0",
"@opentelemetry/api": "^1.1.0",
- "@opentelemetry/api-metrics": "^0.30.0",
+ "@opentelemetry/api-metrics": "^0.31.0",
"@opentelemetry/exporter-jaeger": "^1.3.1",
- "@opentelemetry/exporter-prometheus": "~0.30.0",
- "@opentelemetry/instrumentation": "^0.30.0",
+ "@opentelemetry/exporter-prometheus": "~0.31.0",
+ "@opentelemetry/instrumentation": "^0.31.0",
"@opentelemetry/instrumentation-dns": "^0.29.0",
"@opentelemetry/instrumentation-express": "^0.30.0",
"@opentelemetry/instrumentation-fs": "^0.4.0",
- "@opentelemetry/instrumentation-http": "^0.30.0",
+ "@opentelemetry/instrumentation-http": "^0.31.0",
"@opentelemetry/instrumentation-pg": "^0.30.0",
"@opentelemetry/instrumentation-redis-4": "^0.31.0",
"@opentelemetry/resources": "^1.3.1",
- "@opentelemetry/sdk-metrics-base": "~0.30.0",
+ "@opentelemetry/sdk-metrics-base": "~0.31.0",
"@opentelemetry/sdk-trace-base": "^1.3.1",
"@opentelemetry/sdk-trace-node": "^1.3.1",
"@opentelemetry/semantic-conventions": "^1.3.1",
import { configRouter } from './config'
import { customPageRouter } from './custom-page'
import { jobsRouter } from './jobs'
+import { metricsRouter } from './metrics'
import { oauthClientsRouter } from './oauth-clients'
import { overviewsRouter } from './overviews'
import { pluginRouter } from './plugins'
import { serverRouter } from './server'
import { usersRouter } from './users'
import { videoChannelRouter } from './video-channel'
+import { videoChannelSyncRouter } from './video-channel-sync'
import { videoPlaylistRouter } from './video-playlist'
import { videosRouter } from './videos'
-import { videoChannelSyncRouter } from './video-channel-sync'
const apiRouter = express.Router()
apiRouter.use('/video-playlists', videoPlaylistRouter)
apiRouter.use('/videos', videosRouter)
apiRouter.use('/jobs', jobsRouter)
+apiRouter.use('/metrics', metricsRouter)
apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/plugins', pluginRouter)
--- /dev/null
+import express from 'express'
+import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics'
+import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models'
+import { addPlaybackMetricValidator, asyncMiddleware } from '../../middlewares'
+
+const metricsRouter = express.Router()
+
+metricsRouter.post('/playback',
+ asyncMiddleware(addPlaybackMetricValidator),
+ addPlaybackMetric
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ metricsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+function addPlaybackMetric (req: express.Request, res: express.Response) {
+ const body: PlaybackMetricCreate = req.body
+
+ OpenTelemetryMetrics.Instance.observePlaybackMetric(res.locals.onlyImmutableVideo, body)
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
--- /dev/null
+function isValidPlayerMode (value: any) {
+ return value === 'webtorrent' || value === 'p2p-media-loader'
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isValidPlayerMode
+}
export * from './lives-observers-builder'
export * from './job-queue-observers-builder'
export * from './nodejs-observers-builder'
+export * from './playback-metrics'
export * from './stats-observers-builder'
export * from './viewers-observers-builder'
import { constants, PerformanceObserver } from 'perf_hooks'
import * as process from 'process'
import { Meter, ObservableResult } from '@opentelemetry/api-metrics'
-import { ExplicitBucketHistogramAggregation, MeterProvider } from '@opentelemetry/sdk-metrics-base'
+import { ExplicitBucketHistogramAggregation } from '@opentelemetry/sdk-metrics-base'
import { View } from '@opentelemetry/sdk-metrics-base/build/src/view/View'
import { logger } from '@server/helpers/logger'
export class NodeJSObserversBuilder {
- constructor (private readonly meter: Meter, private readonly meterProvider: MeterProvider) {
+ constructor (private readonly meter: Meter) {
+ }
+
+ static getViews () {
+ return [
+ new View({
+ aggregation: new ExplicitBucketHistogramAggregation([ 0.001, 0.01, 0.1, 1, 2, 5 ]),
+ instrumentName: 'nodejs_gc_duration_seconds'
+ })
+ ]
}
buildObservers () {
[constants.NODE_PERFORMANCE_GC_WEAKCB]: 'weakcb'
}
- this.meterProvider.addView(
- new View({ aggregation: new ExplicitBucketHistogramAggregation([ 0.001, 0.01, 0.1, 1, 2, 5 ]) }),
- { instrument: { name: 'nodejs_gc_duration_seconds' } }
- )
-
const histogram = this.meter.createHistogram('nodejs_gc_duration_seconds', {
description: 'Garbage collection duration by kind, one of major, minor, incremental or weakcb'
})
--- /dev/null
+import { Counter, Meter } from '@opentelemetry/api-metrics'
+import { MVideoImmutable } from '@server/types/models'
+import { PlaybackMetricCreate } from '@shared/models'
+
+export class PlaybackMetrics {
+ private errorsCounter: Counter
+ private resolutionChangesCounter: Counter
+
+ private downloadedBytesP2PCounter: Counter
+ private uploadedBytesP2PCounter: Counter
+
+ private downloadedBytesHTTPCounter: Counter
+
+ constructor (private readonly meter: Meter) {
+
+ }
+
+ buildCounters () {
+ this.errorsCounter = this.meter.createCounter('peertube_playback_errors_count', {
+ description: 'Errors collected from PeerTube player.'
+ })
+
+ this.resolutionChangesCounter = this.meter.createCounter('peertube_playback_resolution_changes_count', {
+ description: 'Resolution changes collected from PeerTube player.'
+ })
+
+ this.downloadedBytesHTTPCounter = this.meter.createCounter('peertube_playback_http_downloaded_bytes', {
+ description: 'Downloaded bytes with HTTP by PeerTube player.'
+ })
+ this.downloadedBytesP2PCounter = this.meter.createCounter('peertube_playback_p2p_downloaded_bytes', {
+ description: 'Downloaded bytes with P2P by PeerTube player.'
+ })
+
+ this.uploadedBytesP2PCounter = this.meter.createCounter('peertube_playback_p2p_uploaded_bytes', {
+ description: 'Uploaded bytes with P2P by PeerTube player.'
+ })
+ }
+
+ observe (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
+ const attributes = {
+ videoOrigin: video.remote
+ ? 'remote'
+ : 'local',
+
+ playerMode: metrics.playerMode,
+
+ resolution: metrics.resolution + '',
+ fps: metrics.fps + ''
+ }
+
+ this.errorsCounter.add(metrics.errors, attributes)
+ this.resolutionChangesCounter.add(metrics.resolutionChanges, attributes)
+
+ this.downloadedBytesHTTPCounter.add(metrics.downloadedBytesHTTP, attributes)
+ this.downloadedBytesP2PCounter.add(metrics.downloadedBytesP2P, attributes)
+
+ this.uploadedBytesP2PCounter.add(metrics.uploadedBytesP2P, attributes)
+ }
+}
import { MeterProvider } from '@opentelemetry/sdk-metrics-base'
import { logger } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config'
+import { MVideoImmutable } from '@server/types/models'
+import { PlaybackMetricCreate } from '@shared/models'
import {
JobQueueObserversBuilder,
LivesObserversBuilder,
NodeJSObserversBuilder,
+ PlaybackMetrics,
StatsObserversBuilder,
ViewersObserversBuilder
} from './metric-helpers'
private onRequestDuration: (req: Request, res: Response) => void
+ private playbackMetrics: PlaybackMetrics
+
private constructor () {}
init (app: Application) {
logger.info('Registering Open Telemetry metrics')
- const provider = new MeterProvider()
+ const provider = new MeterProvider({
+ views: [
+ ...NodeJSObserversBuilder.getViews()
+ ]
+ })
provider.addMetricReader(new PrometheusExporter({ port: CONFIG.OPEN_TELEMETRY.METRICS.PROMETHEUS_EXPORTER.PORT }))
this.buildRequestObserver()
- const nodeJSObserversBuilder = new NodeJSObserversBuilder(this.meter, provider)
+ this.playbackMetrics = new PlaybackMetrics(this.meter)
+ this.playbackMetrics.buildCounters()
+
+ const nodeJSObserversBuilder = new NodeJSObserversBuilder(this.meter)
nodeJSObserversBuilder.buildObservers()
const jobQueueObserversBuilder = new JobQueueObserversBuilder(this.meter)
viewersObserversBuilder.buildObservers()
}
+ observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
+ this.playbackMetrics.observe(video, metrics)
+ }
+
private buildRequestObserver () {
const requestDuration = this.meter.createHistogram('http_request_duration_ms', {
unit: 'milliseconds',
export * from './feeds'
export * from './follows'
export * from './jobs'
+export * from './metrics'
export * from './logs'
export * from './oembed'
export * from './pagination'
--- /dev/null
+import express from 'express'
+import { body } from 'express-validator'
+import { isValidPlayerMode } from '@server/helpers/custom-validators/metrics'
+import { isIdOrUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
+import { CONFIG } from '@server/initializers/config'
+import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors, doesVideoExist } from './shared'
+
+const addPlaybackMetricValidator = [
+ body('resolution')
+ .isInt({ min: 0 }).withMessage('Invalid resolution'),
+ body('fps')
+ .optional()
+ .isInt({ min: 0 }).withMessage('Invalid fps'),
+ body('playerMode')
+ .custom(isValidPlayerMode).withMessage('Invalid playerMode'),
+
+ body('resolutionChanges')
+ .isInt({ min: 0 }).withMessage('Invalid resolutionChanges'),
+
+ body('errors')
+ .isInt({ min: 0 }).withMessage('Invalid errors'),
+
+ body('downloadedBytesP2P')
+ .isInt({ min: 0 }).withMessage('Invalid downloadedBytesP2P'),
+ body('downloadedBytesHTTP')
+ .isInt({ min: 0 }).withMessage('Invalid downloadedBytesHTTP'),
+
+ body('uploadedBytesP2P')
+ .isInt({ min: 0 }).withMessage('Invalid uploadedBytesP2P'),
+
+ body('videoId')
+ .customSanitizer(toCompleteUUID)
+ .optional()
+ .custom(isIdOrUUIDValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking addPlaybackMetricValidator parameters.', { parameters: req.query })
+
+ if (!CONFIG.OPEN_TELEMETRY.METRICS.ENABLED) return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+
+ const body: PlaybackMetricCreate = req.body
+
+ if (areValidationErrors(req, res)) return
+ if (!await doesVideoExist(body.videoId, res, 'only-immutable-attributes')) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ addPlaybackMetricValidator
+}
import './jobs'
import './live'
import './logs'
+import './metrics'
import './my-user'
import './plugins'
import './redundancy'
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import { omit } from 'lodash'
+import { HttpStatusCode, PlaybackMetricCreate, VideoResolution } from '@shared/models'
+import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test metrics API validators', function () {
+ let server: PeerTubeServer
+ let videoUUID: string
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(120000)
+
+ server = await createSingleServer(1, {
+ open_telemetry: {
+ metrics: {
+ enabled: true
+ }
+ }
+ })
+
+ await setAccessTokensToServers([ server ])
+
+ const { uuid } = await server.videos.quickUpload({ name: 'video' })
+ videoUUID = uuid
+ })
+
+ describe('When adding playback metrics', function () {
+ const path = '/api/v1/metrics/playback'
+ let baseParams: PlaybackMetricCreate
+
+ before(function () {
+ baseParams = {
+ playerMode: 'p2p-media-loader',
+ resolution: VideoResolution.H_1080P,
+ fps: 30,
+ resolutionChanges: 1,
+ errors: 2,
+ downloadedBytesP2P: 0,
+ downloadedBytesHTTP: 0,
+ uploadedBytesP2P: 0,
+ videoId: videoUUID
+ }
+ })
+
+ it('Should fail with an invalid resolution', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, resolution: 'toto' }
+ })
+ })
+
+ it('Should fail with an invalid fps', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, fps: 'toto' }
+ })
+ })
+
+ it('Should fail with a missing/invalid player mode', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'playerMode')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, playerMode: 'toto' }
+ })
+ })
+
+ it('Should fail with an missing/invalid resolution changes', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'resolutionChanges')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, resolutionChanges: 'toto' }
+ })
+ })
+
+ it('Should fail with a missing errors', async function () {
+
+ })
+
+ it('Should fail with an missing/invalid errors', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'errors')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, errors: 'toto' }
+ })
+ })
+
+ it('Should fail with an missing/invalid downloadedBytesP2P', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'downloadedBytesP2P')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, downloadedBytesP2P: 'toto' }
+ })
+ })
+
+ it('Should fail with an missing/invalid downloadedBytesHTTP', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'downloadedBytesHTTP')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, downloadedBytesHTTP: 'toto' }
+ })
+ })
+
+ it('Should fail with an missing/invalid uploadedBytesP2P', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: omit(baseParams, 'uploadedBytesP2P')
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, uploadedBytesP2P: 'toto' }
+ })
+ })
+
+ it('Should fail with a bad video id', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, videoId: 'toto' }
+ })
+ })
+
+ it('Should fail with an unknown video', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: { ...baseParams, videoId: 42 },
+ expectedStatus: HttpStatusCode.NOT_FOUND_404
+ })
+ })
+
+ it('Should succeed with the correct params', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: baseParams,
+ expectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ })
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
import { expect } from 'chai'
import { expectLogContain, expectLogDoesNotContain, MockHTTP } from '@server/tests/shared'
-import { HttpStatusCode, VideoPrivacy } from '@shared/models'
+import { HttpStatusCode, VideoPrivacy, VideoResolution } from '@shared/models'
import { cleanupTests, createSingleServer, makeRawRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
describe('Open Telemetry', function () {
let server: PeerTubeServer
describe('Metrics', function () {
- const metricsUrl = 'http://localhost:9091/metrics'
+ const metricsUrl = 'http://localhost:9092/metrics'
it('Should not enable open telemetry metrics', async function () {
server = await createSingleServer(1)
})
const res = await makeRawRequest(metricsUrl, HttpStatusCode.OK_200)
- expect(res.text).to.contain('peertube_job_queue_total')
+ expect(res.text).to.contain('peertube_job_queue_total{')
+ })
+
+ it('Should have playback metrics', async function () {
+ await setAccessTokensToServers([ server ])
+
+ const video = await server.videos.quickUpload({ name: 'video' })
+
+ await server.metrics.addPlaybackMetric({
+ metrics: {
+ playerMode: 'p2p-media-loader',
+ resolution: VideoResolution.H_1080P,
+ fps: 30,
+ resolutionChanges: 1,
+ errors: 2,
+ downloadedBytesP2P: 0,
+ downloadedBytesHTTP: 0,
+ uploadedBytesP2P: 5,
+ videoId: video.uuid
+ }
+ })
+ const res = await makeRawRequest(metricsUrl, HttpStatusCode.OK_200)
+ expect(res.text).to.contain('peertube_playback_http_uploaded_bytes_total{')
+ })
+
+ after(async function () {
await server.kill()
})
})
export * from './feeds'
export * from './http'
export * from './joinpeertube'
+export * from './metrics'
export * from './moderation'
export * from './overviews'
export * from './plugins'
--- /dev/null
+export * from './playback-metric-create.model'
--- /dev/null
+import { VideoResolution } from '../videos'
+
+export interface PlaybackMetricCreate {
+ playerMode: 'p2p-media-loader' | 'webtorrent'
+
+ resolution?: VideoResolution
+ fps?: number
+
+ resolutionChanges: number
+
+ errors: number
+
+ downloadedBytesP2P: number
+ downloadedBytesHTTP: number
+
+ uploadedBytesP2P: number
+
+ videoId: number | string
+}
export * from './follows'
export * from './jobs'
export * from './jobs-command'
+export * from './metrics-command'
export * from './object-storage-command'
export * from './plugins-command'
export * from './redundancy-command'
--- /dev/null
+import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class MetricsCommand extends AbstractCommand {
+
+ addPlaybackMetric (options: OverrideCommandOptions & { metrics: PlaybackMetricCreate }) {
+ const path = '/api/v1/metrics/playback'
+
+ return this.postBodyRequest({
+ ...options,
+
+ path,
+ fields: options.metrics,
+ implicitToken: false,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+}
import { DebugCommand } from './debug-command'
import { FollowsCommand } from './follows-command'
import { JobsCommand } from './jobs-command'
+import { MetricsCommand } from './metrics-command'
import { ObjectStorageCommand } from './object-storage-command'
import { PluginsCommand } from './plugins-command'
import { RedundancyCommand } from './redundancy-command'
debug?: DebugCommand
follows?: FollowsCommand
jobs?: JobsCommand
+ metrics?: MetricsCommand
plugins?: PluginsCommand
redundancy?: RedundancyCommand
stats?: StatsCommand
this.debug = new DebugCommand(this)
this.follows = new FollowsCommand(this)
this.jobs = new JobsCommand(this)
+ this.metrics = new MetricsCommand(this)
this.plugins = new PluginsCommand(this)
this.redundancy = new RedundancyCommand(this)
this.stats = new StatsCommand(this)
'404':
description: plugin not found
+ /metrics/playback:
+ post:
+ summary: Create playback metrics
+ description: These metrics are exposed by OpenTelemetry metrics exporter if enabled.
+ tags:
+ - Stats
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaybackMetricCreate'
+ responses:
+ '204':
+ description: successful operation
+
servers:
- url: 'https://peertube2.cpy.re/api/v1'
description: Live Test Server (live data - latest nightly version)
format: binary
LiveVideoSessionResponse:
- properties:
- id:
- type: integer
- startDate:
- type: string
- format: date-time
- description: Start date of the live session
- endDate:
- type: string
- format: date-time
- nullable: true
- description: End date of the live session
- error:
- type: integer
- enum:
- - 1
- - 2
- - 3
- - 4
- - 5
- nullable: true
- description: >
- Error type if an error occurred during the live session:
- - `1`: Bad socket health (transcoding is too slow)
- - `2`: Max duration exceeded
- - `3`: Quota exceeded
- - `4`: Quota FFmpeg error
- - `5`: Video has been blacklisted during the live
- replayVideo:
- type: object
- description: Video replay information
- properties:
- id:
- type: number
- uuid:
- $ref: '#/components/schemas/UUIDv4'
- shortUUID:
- $ref: '#/components/schemas/shortUUID'
+ properties:
+ id:
+ type: integer
+ startDate:
+ type: string
+ format: date-time
+ description: Start date of the live session
+ endDate:
+ type: string
+ format: date-time
+ nullable: true
+ description: End date of the live session
+ error:
+ type: integer
+ enum:
+ - 1
+ - 2
+ - 3
+ - 4
+ - 5
+ nullable: true
+ description: >
+ Error type if an error occurred during the live session:
+ - `1`: Bad socket health (transcoding is too slow)
+ - `2`: Max duration exceeded
+ - `3`: Quota exceeded
+ - `4`: Quota FFmpeg error
+ - `5`: Video has been blacklisted during the live
+ replayVideo:
+ type: object
+ description: Video replay information
+ properties:
+ id:
+ type: number
+ uuid:
+ $ref: '#/components/schemas/UUIDv4'
+ shortUUID:
+ $ref: '#/components/schemas/shortUUID'
+
+ PlaybackMetricCreate:
+ properties:
+ playerMode:
+ type: string
+ enum:
+ - 'p2p-media-loader'
+ - 'webtorrent'
+ resolution:
+ type: number
+ description: Current player video resolution
+ fps:
+ type: number
+ description: Current player video fps
+ resolutionChanges:
+ type: number
+ description: How many resolution changes occured since the last metric creation
+ errors:
+ type: number
+ description: How many errors occured since the last metric creation
+ downloadedBytesP2P:
+ type: number
+ description: How many bytes were downloaded with P2P since the last metric creation
+ downloadedBytesHTTP:
+ type: number
+ description: How many bytes were downloaded with HTTP since the last metric creation
+ uploadedBytesP2P:
+ type: number
+ description: How many bytes were uploaded with P2P since the last metric creation
+ videoId:
+ oneOf:
+ - $ref: '#/components/schemas/id'
+ - $ref: '#/components/schemas/UUIDv4'
+ - $ref: '#/components/schemas/shortUUID'
+ required:
+ - playerMode
+ - resolutionChanges
+ - errors
+ - downloadedBytesP2P
+ - downloadedBytesHTTP
+ - uploadedBytesP2P
+ - videoId
callbacks:
searchIndex:
dependencies:
"@opentelemetry/api" "^1.0.0"
-"@opentelemetry/api-metrics@0.30.0", "@opentelemetry/api-metrics@^0.30.0":
- version "0.30.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.30.0.tgz#b5defd10756e81d1c7ce8669ff8a8d2465ba0be8"
- integrity sha512-jSb7iiYPY+DSUKIyzfGt0a5K1QGzWY5fSWtUB8Alfi27NhQGHBeuYYC5n9MaBP/HNWw5GpEIhXGEYCF9Pf8IEg==
+"@opentelemetry/api-metrics@0.31.0", "@opentelemetry/api-metrics@^0.31.0":
+ version "0.31.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.31.0.tgz#0ed4cf4d7c731f968721c2b303eaf5e9fd42f736"
+ integrity sha512-PcL1x0kZtMie7NsNy67OyMvzLEXqf3xd0TZJKHHPMGTe89oMpNVrD1zJB1kZcwXOxLlHHb6tz21G3vvXPdXyZg==
dependencies:
"@opentelemetry/api" "^1.0.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.5.0.tgz#4955313e7f0ec0fe17c813328a2a7f39f262c0fa"
integrity sha512-mhBPP0BU0RaH2HB8U4MDd5OjWA1y7SoLOovCT0iEpJAltaq2z04uxRJVzIs91vkpNnV0utUZowQQD3KElgU+VA==
-"@opentelemetry/core@1.4.0":
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.4.0.tgz#26839ab9e36583a174273a1e1c5b33336c163725"
- integrity sha512-faq50VFEdyC7ICAOlhSi+yYZ+peznnGjTJToha9R63i9fVopzpKrkZt7AIdXUmz2+L2OqXrcJs7EIdN/oDyr5w==
- dependencies:
- "@opentelemetry/semantic-conventions" "1.4.0"
-
"@opentelemetry/core@1.5.0", "@opentelemetry/core@^1.0.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.5.0.tgz#717bceee15d4c69d4c7321c1fe0f5a562b60eb81"
"@opentelemetry/semantic-conventions" "1.5.0"
jaeger-client "^3.15.0"
-"@opentelemetry/exporter-prometheus@~0.30.0":
- version "0.30.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.30.0.tgz#f81322d3cb000170e716bc76820600d5649be538"
- integrity sha512-y0SXvpzoKR+Tk/UL6F1f7vAcCzqpCDP/cTEa+Z7sX57aEG0HDXLQiLmAgK/BHqcEN5MFQMZ+MDVDsUrvpa6/Jw==
+"@opentelemetry/exporter-prometheus@~0.31.0":
+ version "0.31.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.31.0.tgz#b0696be42542a961ec1145f3754a845efbda942e"
+ integrity sha512-EfWFzoCu/THw0kZiaA2RUrk6XIQbfaJHJ26LRrVIK7INwosW8Q+x4pGfiJ5nxhglYiG9OTqGrQ6nQ4T9q1UMpg==
dependencies:
- "@opentelemetry/api-metrics" "0.30.0"
- "@opentelemetry/core" "1.4.0"
- "@opentelemetry/sdk-metrics-base" "0.30.0"
+ "@opentelemetry/api-metrics" "0.31.0"
+ "@opentelemetry/core" "1.5.0"
+ "@opentelemetry/sdk-metrics-base" "0.31.0"
"@opentelemetry/instrumentation-dns@^0.29.0":
version "0.29.0"
"@opentelemetry/instrumentation" "^0.29.2"
"@opentelemetry/semantic-conventions" "^1.0.0"
-"@opentelemetry/instrumentation-http@^0.30.0":
- version "0.30.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.30.0.tgz#312ef25defbff750dd9082356bb9a9137ed5fd82"
- integrity sha512-OhiuzR2mhlTcaXD1dYW/dqnC/zjIKHp2NWMUyDHEd4xS6NZAiTU5mNDv57Y9on+/VwYXWUZZ2tB7AOVPsFUIOg==
+"@opentelemetry/instrumentation-http@^0.31.0":
+ version "0.31.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.31.0.tgz#5c6dea9cdb636543c6ed1f1a4e55d4422e50fa89"
+ integrity sha512-DLw+H7UQZ+V3FX72iGXVMX4ylL4jV+GHraaUiVY0CIdxg1nrGmjLm4dPU5500IXlbgZUUoJ9jq02JDblujdKcQ==
dependencies:
- "@opentelemetry/core" "1.4.0"
- "@opentelemetry/instrumentation" "0.30.0"
- "@opentelemetry/semantic-conventions" "1.4.0"
+ "@opentelemetry/core" "1.5.0"
+ "@opentelemetry/instrumentation" "0.31.0"
+ "@opentelemetry/semantic-conventions" "1.5.0"
semver "^7.3.5"
"@opentelemetry/instrumentation-pg@^0.30.0":
"@opentelemetry/instrumentation" "^0.29.2"
"@opentelemetry/semantic-conventions" "^1.0.0"
-"@opentelemetry/instrumentation@0.30.0", "@opentelemetry/instrumentation@^0.30.0":
- version "0.30.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.30.0.tgz#97cca611bd276439cc4e01e0516e50cbbb1e3459"
- integrity sha512-9bjRx81B6wbJ7CGWc/WCUfcb0QIG5UIcjnPTzwYIURjYPd8d0ZzRlrnqEdQG62jn4lSPEvnNqTlyC7qXtn9nAA==
+"@opentelemetry/instrumentation@0.31.0", "@opentelemetry/instrumentation@^0.31.0":
+ version "0.31.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.31.0.tgz#bee0052a86e22f57be3901c44234f1a210bcfda8"
+ integrity sha512-b2hFebXPtBcut4d81b8Kg6GiCoAS8nxb8kYSronQYAXxwNSetqHwIJ2nKLo1slFH1UWUXn0zi3eDez2Sn/9uMQ==
dependencies:
- "@opentelemetry/api-metrics" "0.30.0"
+ "@opentelemetry/api-metrics" "0.31.0"
require-in-the-middle "^5.0.3"
semver "^7.3.2"
shimmer "^1.2.1"
dependencies:
"@opentelemetry/core" "1.5.0"
-"@opentelemetry/resources@1.4.0":
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.4.0.tgz#5e23b0d7976158861059dec17e0ee36a35a5ab85"
- integrity sha512-Q3pI5+pCM+Ur7YwK9GbG89UBipwJbfmuzSPAXTw964ZHFzSrz+JAgrETC9rqsUOYdUlj/V7LbRMG5bo72xE0Xw==
- dependencies:
- "@opentelemetry/core" "1.4.0"
- "@opentelemetry/semantic-conventions" "1.4.0"
-
"@opentelemetry/resources@1.5.0", "@opentelemetry/resources@^1.3.1":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.5.0.tgz#ce7fbdaec3494e41bc279ddbed3c478ee2570b03"
"@opentelemetry/core" "1.5.0"
"@opentelemetry/semantic-conventions" "1.5.0"
-"@opentelemetry/sdk-metrics-base@0.30.0", "@opentelemetry/sdk-metrics-base@~0.30.0":
- version "0.30.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics-base/-/sdk-metrics-base-0.30.0.tgz#242d9260a89a1ac2bf1e167b3fda758f3883c769"
- integrity sha512-3BDg1MYDInDyGvy+bSH8OuCX5nsue7omH6Y2eidCGTTDYRPxDmq9tsRJxnTUepoMAvWX+1sTwZ4JqTFmc1z8Mw==
+"@opentelemetry/sdk-metrics-base@0.31.0", "@opentelemetry/sdk-metrics-base@~0.31.0":
+ version "0.31.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics-base/-/sdk-metrics-base-0.31.0.tgz#f797da702c8d9862a2fff55a1e7c70aa6845e535"
+ integrity sha512-4R2Bjl3wlqIGcq4bCoI9/pD49ld+tEoM9n85UfFzr/aUe+2huY2jTPq/BP9SVB8d2Zfg7mGTIFeapcEvAdKK7g==
dependencies:
- "@opentelemetry/api-metrics" "0.30.0"
- "@opentelemetry/core" "1.4.0"
- "@opentelemetry/resources" "1.4.0"
+ "@opentelemetry/api-metrics" "0.31.0"
+ "@opentelemetry/core" "1.5.0"
+ "@opentelemetry/resources" "1.5.0"
lodash.merge "4.6.2"
"@opentelemetry/sdk-trace-base@1.5.0", "@opentelemetry/sdk-trace-base@^1.3.1":
"@opentelemetry/sdk-trace-base" "1.5.0"
semver "^7.3.5"
-"@opentelemetry/semantic-conventions@1.4.0":
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.4.0.tgz#facf2c67d6063b9918d5a5e3fdf25f3a30d547b6"
- integrity sha512-Hzl8soGpmyzja9w3kiFFcYJ7n5HNETpplY6cb67KR4QPlxp4FTTresO06qXHgHDhyIInmbLJXuwARjjpsKYGuQ==
-
"@opentelemetry/semantic-conventions@1.5.0", "@opentelemetry/semantic-conventions@^1.0.0", "@opentelemetry/semantic-conventions@^1.3.1":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.5.0.tgz#cea9792bfcf556c87ded17c6ac729348697bb632"