Add playback metric endpoint sent to OTEL

pull/5215/head
Chocobozzz 2022-08-12 16:41:29 +02:00
parent 0e6cd1c00f
commit fd3c2e8705
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
35 changed files with 748 additions and 127 deletions

View File

@ -628,6 +628,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
: null,
authorizationHeader: this.authService.getRequestHeaderValue(),
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
embedUrl: video.embedUrl,
embedTitle: video.name,
instanceName: this.serverConfig.instance.name,

View File

@ -22,6 +22,7 @@ import './shared/playlist/playlist-plugin'
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'

View File

@ -87,9 +87,9 @@ class P2pInfoButton extends Button {
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'

View File

@ -44,6 +44,14 @@ export class ManagerOptionsBuilder {
'isLive',
'videoUUID'
])
},
metrics: {
mode: this.mode,
...pick(commonOptions, [
'metricsUrl',
'videoUUID'
])
}
}

View File

@ -0,0 +1 @@
export * from './metrics-plugin'

View File

@ -0,0 +1,128 @@
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 }

View File

@ -2,10 +2,10 @@ import Hlsjs from 'hls.js'
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)
@ -29,9 +29,7 @@ class P2pMediaLoaderPlugin extends Plugin {
}
private statsHTTPBytes = {
pendingDownload: [] as number[],
pendingUpload: [] as number[],
totalDownload: 0,
totalUpload: 0
totalDownload: 0
}
private startTime: number
@ -123,6 +121,8 @@ class P2pMediaLoaderPlugin extends Plugin {
this.statsP2PBytes.numPeers = 1 + this.options.redundancyUrlManager.countBaseUrls()
this.runStats()
this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange'))
}
private runStats () {
@ -134,10 +134,13 @@ class P2pMediaLoaderPlugin extends Plugin {
})
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++)
@ -148,20 +151,16 @@ class P2pMediaLoaderPlugin extends Plugin {
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,

View File

@ -144,6 +144,8 @@ class PeerTubePlugin extends Plugin {
this.listenFullScreenChange()
}
// ---------------------------------------------------------------------------
private runUserViewing () {
let lastCurrentTime = this.startTime
let lastViewEvent: VideoViewEvent
@ -205,6 +207,8 @@ class PeerTubePlugin extends Plugin {
return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
}
// ---------------------------------------------------------------------------
private listenFullScreenChange () {
this.player.on('fullscreenchange', () => {
if (this.player.isFullscreen()) this.player.focus()

View File

@ -95,9 +95,9 @@ class StatsCard extends Component {
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'

View File

@ -204,6 +204,8 @@ class WebTorrentPlugin extends Plugin {
}
this.updateVideoFile(newVideoFile, options)
this.player.trigger('engineResolutionChange')
}
flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
@ -506,9 +508,7 @@ class WebTorrentPlugin extends Plugin {
source: 'webtorrent',
http: {
downloadSpeed: 0,
uploadSpeed: 0,
downloaded: 0,
uploaded: 0
downloaded: 0
},
p2p: {
downloadSpeed: this.torrent.downloadSpeed,

View File

@ -59,6 +59,8 @@ export interface CommonOptions extends CustomizationOptions {
videoViewUrl: string
authorizationHeader?: string
metricsUrl: string
embedUrl: string
embedTitle: string

View File

@ -109,6 +109,12 @@ type PeerTubePluginOptions = {
videoUUID: string
}
type MetricsPluginOptions = {
mode: PlayerMode
metricsUrl: string
videoUUID: string
}
type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
@ -165,6 +171,7 @@ type VideoJSPluginOptions = {
playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions
metrics: MetricsPluginOptions
webtorrent?: WebtorrentPluginOptions
@ -197,9 +204,7 @@ type PlayerNetworkInfo = {
http: {
downloadSpeed: number
uploadSpeed: number
downloaded: number
uploaded: number
}
p2p: {
@ -227,6 +232,7 @@ export {
ResolutionUpdateData,
AutoResolutionUpdateData,
PlaylistPluginOptions,
MetricsPluginOptions,
VideoJSCaption,
PeerTubePluginOptions,
WebtorrentPluginOptions,

View File

@ -203,6 +203,7 @@ export class PlayerManagerOptions {
videoCaptions,
inactivityTimeout: 2500,
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
metricsUrl: window.location.origin + '/api/v1/metrics/playback',
videoShortUUID: video.shortUUID,
videoUUID: video.uuid,

View File

@ -148,3 +148,8 @@ geo_ip:
video_studio:
enabled: true
open_telemetry:
metrics:
prometheus_exporter:
port: 9092

View File

@ -86,18 +86,18 @@
"@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",

View File

@ -11,6 +11,7 @@ import { bulkRouter } from './bulk'
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'
@ -18,9 +19,9 @@ import { searchRouter } from './search'
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()
@ -48,6 +49,7 @@ apiRouter.use('/video-channel-syncs', videoChannelSyncRouter)
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)

View File

@ -0,0 +1,27 @@
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)
}

View File

@ -0,0 +1,9 @@
function isValidPlayerMode (value: any) {
return value === 'webtorrent' || value === 'p2p-media-loader'
}
// ---------------------------------------------------------------------------
export {
isValidPlayerMode
}

View File

@ -1,5 +1,6 @@
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'

View File

@ -2,7 +2,7 @@ import { readdir } from 'fs-extra'
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'
@ -12,7 +12,16 @@ 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 () {
@ -91,11 +100,6 @@ export class NodeJSObserversBuilder {
[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'
})

View File

@ -0,0 +1,59 @@
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)
}
}

View File

@ -4,10 +4,13 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'
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'
@ -20,6 +23,8 @@ class OpenTelemetryMetrics {
private onRequestDuration: (req: Request, res: Response) => void
private playbackMetrics: PlaybackMetrics
private constructor () {}
init (app: Application) {
@ -41,7 +46,11 @@ class OpenTelemetryMetrics {
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 }))
@ -51,7 +60,10 @@ class OpenTelemetryMetrics {
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)
@ -67,6 +79,10 @@ class OpenTelemetryMetrics {
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',

View File

@ -10,6 +10,7 @@ export * from './express'
export * from './feeds'
export * from './follows'
export * from './jobs'
export * from './metrics'
export * from './logs'
export * from './oembed'
export * from './pagination'

View File

@ -0,0 +1,56 @@
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
}

View File

@ -10,6 +10,7 @@ import './follows'
import './jobs'
import './live'
import './logs'
import './metrics'
import './my-user'
import './plugins'
import './redundancy'

View File

@ -0,0 +1,183 @@
/* 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 ])
})
})

View File

@ -2,14 +2,14 @@
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)
@ -36,8 +36,33 @@ describe('Open Telemetry', function () {
})
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()
})
})

View File

@ -6,6 +6,7 @@ export * from './custom-markup'
export * from './feeds'
export * from './http'
export * from './joinpeertube'
export * from './metrics'
export * from './moderation'
export * from './overviews'
export * from './plugins'

View File

@ -0,0 +1 @@
export * from './playback-metric-create.model'

View File

@ -0,0 +1,19 @@
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
}

View File

@ -5,6 +5,7 @@ export * from './follows-command'
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'

View File

@ -0,0 +1,18 @@
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
})
}
}

View File

@ -37,6 +37,7 @@ import { ContactFormCommand } from './contact-form-command'
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'
@ -104,6 +105,7 @@ export class PeerTubeServer {
debug?: DebugCommand
follows?: FollowsCommand
jobs?: JobsCommand
metrics?: MetricsCommand
plugins?: PluginsCommand
redundancy?: RedundancyCommand
stats?: StatsCommand
@ -377,6 +379,7 @@ export class PeerTubeServer {
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)

View File

@ -5009,6 +5009,21 @@ paths:
'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)
@ -8195,44 +8210,86 @@ components:
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:

View File

@ -1616,10 +1616,10 @@
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"
@ -1633,13 +1633,6 @@
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"
@ -1657,14 +1650,14 @@
"@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"
@ -1694,14 +1687,14 @@
"@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":
@ -1722,12 +1715,12 @@
"@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"
@ -1756,14 +1749,6 @@
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"
@ -1772,14 +1757,14 @@
"@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":
@ -1803,11 +1788,6 @@
"@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"