Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add user agent video stats #6871

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/app/+stats/video/video-stats.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ my-embed {

.nav-tabs {
@include peertube-nav-tabs($border-width: 2px);

a.nav-link {
padding: 0 10px !important;
}
}

.chart-container {
Expand Down
72 changes: 68 additions & 4 deletions client/src/app/+stats/video/video-stats.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { VideoStatsService } from './video-stats.service'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
Expand All @@ -29,11 +30,13 @@ import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'

type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | 'regions'
const BAR_GRAPHS = [ 'countries', 'regions', 'browser', 'device', 'operatingSystem' ] as const
type BarGraphs = typeof BAR_GRAPHS[number]
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | BarGraphs

type GeoData = { name: string, viewers: number }[]

type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData | VideoStatsUserAgent
type ChartBuilderResult = {
type: 'line' | 'bar'

Expand All @@ -46,6 +49,8 @@ type ChartBuilderResult = {

type Card = { label: string, value: string | number, moreInfo?: string, help?: string }

const isBarGraph = (graphId: ActiveGraphId): graphId is BarGraphs => BAR_GRAPHS.some((graph) => graph === graphId)

ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg')
ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500')
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg')
Expand Down Expand Up @@ -139,6 +144,21 @@ export class VideoStatsComponent implements OnInit {
id: 'regions',
label: $localize`Regions`,
zoomEnabled: false
},
{
id: 'browser',
label: $localize`Browser`,
zoomEnabled: false
},
{
id: 'device',
label: $localize`Device`,
zoomEnabled: false
},
{
id: 'operatingSystem',
label: $localize`Operating system`,
zoomEnabled: false
}
]

Expand Down Expand Up @@ -358,6 +378,9 @@ export class VideoStatsComponent implements OnInit {
private loadChart () {
const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
retention: this.statsService.getRetentionStats(this.video.uuid),
browser: this.statsService.getUserAgentStats(this.video.uuid),
device: this.statsService.getUserAgentStats(this.video.uuid),
operatingSystem: this.statsService.getUserAgentStats(this.video.uuid),

aggregateWatchTime: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
Expand Down Expand Up @@ -392,6 +415,9 @@ export class VideoStatsComponent implements OnInit {
const dataBuilders: {
[ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
} = {
browser: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'browser'),
device: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'device'),
operatingSystem: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'operatingSystem'),
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
Expand All @@ -415,6 +441,7 @@ export class VideoStatsComponent implements OnInit {
scales: {
x: {
ticks: {
stepSize: isBarGraph(graphId) ? 1 : undefined,
callback: function (value) {
return self.formatXTick({
graphId,
Expand Down Expand Up @@ -547,6 +574,43 @@ export class VideoStatsComponent implements OnInit {
}
}

private buildUserAgentChartOptions (rawData: VideoStatsUserAgent, type: 'browser' | 'device' | 'operatingSystem'): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []

for (const d of rawData[type]) {
const name = d.name?.charAt(0).toUpperCase() + d.name?.slice(1)
labels.push(name)
data.push(d.viewers)
}

return {
type: 'bar' as 'bar',

options: {
indexAxis: 'y'
},

displayLegend: true,

plugins: {
...this.buildDisabledZoomPlugin()
},

data: {
labels,
datasets: [
{
label: $localize`Viewers`,
backgroundColor: this.buildChartColor(),
maxBarThickness: 20,
data
}
]
}
}
}

private buildGeoChartOptions (rawData: GeoData): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []
Expand Down Expand Up @@ -627,7 +691,7 @@ export class VideoStatsComponent implements OnInit {

if (graphId === 'retention') return value + ' %'
if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
if ((graphId === 'countries' || graphId === 'regions') && scale) return scale.getLabelForValue(value as number)
if (isBarGraph(graphId) && scale) return scale.getLabelForValue(value as number)

return value.toLocaleString(this.localeId)
}
Expand Down
13 changes: 12 additions & 1 deletion client/src/app/+stats/video/video-stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { environment } from 'src/environments/environment'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@peertube/peertube-models'
import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { VideoService } from '@app/shared/shared-main/video/video.service'

@Injectable({
Expand Down Expand Up @@ -52,4 +58,9 @@ export class VideoStatsService {
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}

getUserAgentStats (videoId: string) {
return this.authHttp.get<VideoStatsUserAgent>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/user-agent')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@
"swagger-cli": "^4.0.2",
"tsc-watch": "^6.0.0",
"tsx": "^4.7.1",
"typescript": "~5.5.2"
"typescript": "~5.5.2",
"ua-parser-js": "^2.0.1"
}
}
5 changes: 4 additions & 1 deletion packages/models/src/plugins/server/server-hook.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ export const serverFilterHookObject = {
// Peertube >= 7.1
'filter:oauth.password-grant.get-user.params': true,
'filter:api.email-verification.ask-send-verify-email.body': true,
'filter:api.users.ask-reset-password.body': true
'filter:api.users.ask-reset-password.body': true,

// Peertube >= 7.2
'filter:api.video-view.parse-user-agent.get.result': true
}

export type ServerFilterHookName = keyof typeof serverFilterHookObject
Expand Down
1 change: 1 addition & 0 deletions packages/models/src/videos/stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './video-stats-retention.model.js'
export * from './video-stats-timeserie-query.model.js'
export * from './video-stats-timeserie-metric.type.js'
export * from './video-stats-timeserie.model.js'
export * from './video-stats-user-agent.model.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type VideoStatsUserAgent = {
[key in 'browser' | 'device' | 'operatingSystem']: {
name: string
viewers: number
}[]
}
17 changes: 16 additions & 1 deletion packages/server-commands/src/videos/video-stats-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'

Expand All @@ -28,6 +29,20 @@ export class VideoStatsCommand extends AbstractCommand {
})
}

getUserAgentStats (options: OverrideCommandOptions & {
videoId: number | string
}) {
const path = '/api/v1/videos/' + options.videoId + '/stats/user-agent'

return this.getRequestBody<VideoStatsUserAgent>({
...options,
path,

implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}

getTimeserieStats (options: OverrideCommandOptions & {
videoId: number | string
metric: VideoStatsTimeserieMetric
Expand Down
10 changes: 9 additions & 1 deletion packages/server-commands/src/videos/views-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ export class ViewsCommand extends AbstractCommand {
viewEvent?: VideoViewEvent
xForwardedFor?: string
sessionId?: string
userAgent?: string
}) {
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
const { id, xForwardedFor, viewEvent, currentTime, sessionId, userAgent } = options
const path = '/api/v1/videos/' + id + '/views'
const headers = userAgent
? {
'User-Agent': userAgent
}
: undefined

return this.postBodyRequest({
...options,

path,
xForwardedFor,
headers,
fields: {
currentTime,
viewEvent,
Expand All @@ -33,6 +40,7 @@ export class ViewsCommand extends AbstractCommand {
id: number | string
xForwardedFor?: string
sessionId?: string
userAgent?: string
}) {
await this.view({ ...options, currentTime: 0 })
await this.view({ ...options, currentTime: 5 })
Expand Down
21 changes: 21 additions & 0 deletions packages/tests/fixtures/peertube-plugin-test/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,27 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
}
})

registerHook({
target: 'filter:api.video-view.parse-user-agent.get.result',
handler: (parsedUserAgent, userAgentStr) => {
if (userAgentStr === 'user agent string') {
return {
browser: {
name: 'Custom browser'
},
device: {
type: 'Custom device'
},
os: {
name: 'Custom os'
}
}
}

return parsedUserAgent
}
})

registerHook({
target: 'filter:video.auto-blacklist.result',
handler: (blacklisted, { video }) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/tests/src/api/check-params/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,36 @@ describe('Test videos views API validators', function () {
})
})

describe('When getting user agent stats', function () {

it('Should fail with a remote video', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId: remoteVideoId,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})

it('Should fail without token', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})

it('Should fail with another token', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId,
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})

it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getUserAgentStats({ videoId })
})
})

after(async function () {
await cleanupTests(servers)
})
Expand Down
62 changes: 62 additions & 0 deletions packages/tests/src/api/views/video-views-user-agent-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { buildUUID } from '@peertube/peertube-node-utils'
import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
import { prepareViewsServers, processViewersStats } from '@tests/shared/views.js'
import { expect } from 'chai'

// eslint-disable-next-line max-len
const EDGE_WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/132.0.0.0'
// eslint-disable-next-line max-len
const EDGE_ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.6943.49 Mobile Safari/537.36 EdgA/131.0.2903.87'
const CHROME_LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'

describe('Test views user agent stats', function () {
let server: PeerTubeServer

before(async function () {
this.timeout(120000)

const servers = await prepareViewsServers({ singleServer: true })
server = servers[0]
})

it('Should report browser, device and OS', async function () {
this.timeout(240000)

const { uuid } = await server.videos.quickUpload({ name: 'video' })
await waitJobs(server)

await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: EDGE_ANDROID_USER_AGENT
})
await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: EDGE_WINDOWS_USER_AGENT
})
await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: CHROME_LINUX_USER_AGENT
})

await processViewersStats([ server ])

const stats = await server.videoStats.getUserAgentStats({ videoId: uuid })

expect(stats.browser).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
expect(stats.browser).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])

expect(stats.device).to.include.deep.members([ { name: 'unknown', viewers: 2 } ])
expect(stats.device).to.include.deep.members([ { name: 'mobile', viewers: 1 } ])

expect(stats.operatingSystem).to.include.deep.members([ { name: 'Android', viewers: 1 } ])
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Linux', viewers: 1 } ])
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Windows', viewers: 1 } ])
})

after(async function () {
await cleanupTests([ server ])
})
})
Loading
Loading