mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-05 15:02:37 +00:00
UBERF-9240 Report session and egress stats to billing service (#7798)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
This commit is contained in:
parent
e6b1ea15c7
commit
5b1efe75cc
143
services/love/src/billing.ts
Normal file
143
services/love/src/billing.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// Copyright © 2025 Hardcore Engineering Inc.
|
||||||
|
//
|
||||||
|
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License. You may
|
||||||
|
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
//
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
import { concatLink, MeasureContext, systemAccountEmail } from '@hcengineering/core'
|
||||||
|
import { generateToken } from '@hcengineering/server-token'
|
||||||
|
import { AccessToken, EgressInfo } from 'livekit-server-sdk'
|
||||||
|
import config from './config'
|
||||||
|
|
||||||
|
// Example:
|
||||||
|
// {
|
||||||
|
// "roomId": "RM_ROOM_ID",
|
||||||
|
// "roomName": "w-room-name",
|
||||||
|
// "numParticipants": 2,
|
||||||
|
// "bandwidth": "1000",
|
||||||
|
// "connectionMinutes": "120",
|
||||||
|
// "startTime": "2025-01-01T12:00:00Z",
|
||||||
|
// "endTime": "2025-01-01T13:00:00Z",
|
||||||
|
// "participants": [ ... ]
|
||||||
|
// }
|
||||||
|
interface LiveKitSession {
|
||||||
|
roomId: string
|
||||||
|
roomName: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
bandwidth: string
|
||||||
|
connectionMinutes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLiveKitSession (ctx: MeasureContext, sessionId: string): Promise<LiveKitSession> {
|
||||||
|
const token = await createAnalyticsToken()
|
||||||
|
const endpoint = `https://cloud-api.livekit.io/api/project/${config.LiveKitProject}/sessions/${sessionId}?v=2`
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
ctx.error('failed to get session analytics', { session: sessionId, status: response.status })
|
||||||
|
throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as LiveKitSession
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to get session analytics')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAnalyticsToken = async (): Promise<string> => {
|
||||||
|
const at = new AccessToken(config.ApiKey, config.ApiSecret, { ttl: '10m' })
|
||||||
|
at.addGrant({ roomList: true })
|
||||||
|
|
||||||
|
return await at.toJwt()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLiveKitSessionBilling (ctx: MeasureContext, sessionId: string): Promise<void> {
|
||||||
|
if (config.BillingUrl === '' || config.LiveKitProject === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getLiveKitSession(ctx, sessionId)
|
||||||
|
const workspace = session.roomName.split('_')[0]
|
||||||
|
const endpoint = concatLink(config.BillingUrl, `/api/v1/billing/${workspace}/livekit/session`)
|
||||||
|
|
||||||
|
const token = generateToken(systemAccountEmail, { name: workspace })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
sessionStart: session.startTime,
|
||||||
|
sessionEnd: session.endTime,
|
||||||
|
bandwidth: Number(session.bandwidth),
|
||||||
|
minutes: Number(session.connectionMinutes),
|
||||||
|
room: session.roomName
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text())
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.error('failed to save session billing', { workspace, session, err })
|
||||||
|
throw new Error('Failed to save session billing: ' + err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLiveKitEgressBilling (ctx: MeasureContext, egress: EgressInfo): Promise<void> {
|
||||||
|
if (config.BillingUrl === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const egressStart = Number(egress.startedAt) / 1000 / 1000
|
||||||
|
const egressEnd = Number(egress.endedAt) / 1000 / 1000
|
||||||
|
const duration = (egressEnd - egressStart) / 1000
|
||||||
|
|
||||||
|
const workspace = egress.roomName.split('_')[0]
|
||||||
|
const endpoint = concatLink(config.BillingUrl, `/api/v1/billing/${workspace}/livekit/egress`)
|
||||||
|
|
||||||
|
const token = generateToken(systemAccountEmail, { name: workspace })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room: egress.roomName,
|
||||||
|
egressId: egress.egressId,
|
||||||
|
egressStart: new Date(egressStart).toISOString(),
|
||||||
|
egressEnd: new Date(egressEnd).toISOString(),
|
||||||
|
duration
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text())
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.error('failed to save egress billing', { workspace, egress, err })
|
||||||
|
throw new Error('Failed to save egress billing: ' + err)
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@ interface Config {
|
|||||||
Port: number
|
Port: number
|
||||||
ServiceID: string
|
ServiceID: string
|
||||||
|
|
||||||
|
LiveKitProject: string
|
||||||
LiveKitHost: string
|
LiveKitHost: string
|
||||||
ApiKey: string
|
ApiKey: string
|
||||||
ApiSecret: string
|
ApiSecret: string
|
||||||
@ -28,12 +29,14 @@ interface Config {
|
|||||||
Secret: string
|
Secret: string
|
||||||
|
|
||||||
MongoUrl: string
|
MongoUrl: string
|
||||||
|
BillingUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const envMap: { [key in keyof Config]: string } = {
|
const envMap: { [key in keyof Config]: string } = {
|
||||||
AccountsURL: 'ACCOUNTS_URL',
|
AccountsURL: 'ACCOUNTS_URL',
|
||||||
Port: 'PORT',
|
Port: 'PORT',
|
||||||
|
|
||||||
|
LiveKitProject: 'LIVEKIT_PROJECT',
|
||||||
LiveKitHost: 'LIVEKIT_HOST',
|
LiveKitHost: 'LIVEKIT_HOST',
|
||||||
ApiKey: 'LIVEKIT_API_KEY',
|
ApiKey: 'LIVEKIT_API_KEY',
|
||||||
ApiSecret: 'LIVEKIT_API_SECRET',
|
ApiSecret: 'LIVEKIT_API_SECRET',
|
||||||
@ -43,7 +46,8 @@ const envMap: { [key in keyof Config]: string } = {
|
|||||||
S3StorageConfig: 'S3_STORAGE_CONFIG',
|
S3StorageConfig: 'S3_STORAGE_CONFIG',
|
||||||
Secret: 'SECRET',
|
Secret: 'SECRET',
|
||||||
ServiceID: 'SERVICE_ID',
|
ServiceID: 'SERVICE_ID',
|
||||||
MongoUrl: 'MONGO_URL'
|
MongoUrl: 'MONGO_URL',
|
||||||
|
BillingUrl: 'BILLING_URL'
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
|
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
|
||||||
@ -52,6 +56,7 @@ const config: Config = (() => {
|
|||||||
const params: Partial<Config> = {
|
const params: Partial<Config> = {
|
||||||
AccountsURL: process.env[envMap.AccountsURL],
|
AccountsURL: process.env[envMap.AccountsURL],
|
||||||
Port: parseNumber(process.env[envMap.Port]) ?? 8096,
|
Port: parseNumber(process.env[envMap.Port]) ?? 8096,
|
||||||
|
LiveKitProject: process.env[envMap.LiveKitProject] ?? '',
|
||||||
LiveKitHost: process.env[envMap.LiveKitHost],
|
LiveKitHost: process.env[envMap.LiveKitHost],
|
||||||
ApiKey: process.env[envMap.ApiKey],
|
ApiKey: process.env[envMap.ApiKey],
|
||||||
ApiSecret: process.env[envMap.ApiSecret],
|
ApiSecret: process.env[envMap.ApiSecret],
|
||||||
@ -60,10 +65,11 @@ const config: Config = (() => {
|
|||||||
S3StorageConfig: process.env[envMap.S3StorageConfig],
|
S3StorageConfig: process.env[envMap.S3StorageConfig],
|
||||||
Secret: process.env[envMap.Secret],
|
Secret: process.env[envMap.Secret],
|
||||||
ServiceID: process.env[envMap.ServiceID] ?? 'love-service',
|
ServiceID: process.env[envMap.ServiceID] ?? 'love-service',
|
||||||
MongoUrl: process.env[envMap.MongoUrl]
|
MongoUrl: process.env[envMap.MongoUrl],
|
||||||
|
BillingUrl: process.env[envMap.BillingUrl] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const optional = ['StorageConfig', 'S3StorageConfig']
|
const optional = ['StorageConfig', 'S3StorageConfig', 'LiveKitProject', 'BillingUrl']
|
||||||
|
|
||||||
const missingEnv = (Object.keys(params) as Array<keyof Config>)
|
const missingEnv = (Object.keys(params) as Array<keyof Config>)
|
||||||
.filter((key) => !optional.includes(key))
|
.filter((key) => !optional.includes(key))
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
WebhookReceiver
|
WebhookReceiver
|
||||||
} from 'livekit-server-sdk'
|
} from 'livekit-server-sdk'
|
||||||
import config from './config'
|
import config from './config'
|
||||||
|
import { saveLiveKitEgressBilling, saveLiveKitSessionBilling } from './billing'
|
||||||
import { getS3UploadParams, saveFile } from './storage'
|
import { getS3UploadParams, saveFile } from './storage'
|
||||||
import { WorkspaceClient } from './workspaceClient'
|
import { WorkspaceClient } from './workspaceClient'
|
||||||
|
|
||||||
@ -96,6 +97,13 @@ export const main = async (): Promise<void> => {
|
|||||||
console.log('no data found for', res.filename)
|
console.log('no data found for', res.filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await saveLiveKitEgressBilling(ctx, event.egressInfo)
|
||||||
|
|
||||||
|
res.send()
|
||||||
|
return
|
||||||
|
} else if (event.event === 'room_finished' && event.room !== undefined) {
|
||||||
|
await saveLiveKitSessionBilling(ctx, event.room.sid)
|
||||||
res.send()
|
res.send()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user