Skip to content
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
11 changes: 11 additions & 0 deletions .changeset/avatar-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@livekit/agents': patch
'@livekit/agents-plugin-anam': patch
'@livekit/agents-plugin-bey': patch
'@livekit/agents-plugin-lemonslice': patch
'@livekit/agents-plugin-liveavatar': patch
'@livekit/agents-plugin-runway': patch
'@livekit/agents-plugin-trugen': patch
---

Add avatar join and playback latency metrics.
15 changes: 14 additions & 1 deletion agents/src/metrics/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type AgentMetrics =
| VADMetrics
| EOUMetrics
| RealtimeModelMetrics
| InterruptionMetrics;
| InterruptionMetrics
| AvatarMetrics;

export type LLMMetrics = {
type: 'llm_metrics';
Expand Down Expand Up @@ -213,3 +214,15 @@ export type InterruptionMetrics = {
numRequests: number;
metadata?: MetricsMetadata;
};

export type AvatarMetrics = {
type: 'avatar_metrics';
timestamp: number;
/** Delay between forwarding the first audio frame to the avatar and playback start. */
playbackLatencyMs?: number;
/** Time when the avatar session was started. */
sessionStartedAt?: number;
/** Time when the avatar participant joined and published a video track. */
avatarJoinedAt?: number;
metadata?: MetricsMetadata;
};
1 change: 1 addition & 0 deletions agents/src/metrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export type {
AgentMetrics,
AvatarMetrics,
EOUMetrics,
InterruptionMetrics,
LLMMetrics,
Expand Down
13 changes: 13 additions & 0 deletions agents/src/metrics/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,18 @@ export const logMetrics = (metrics: AgentMetrics) => {
numRequests: metrics.numRequests,
})
.info('Interruption metrics');
} else if (metrics.type === 'avatar_metrics') {
const extra: Record<string, number | string> = {};
if (metrics.metadata?.modelProvider) extra.modelProvider = metrics.metadata.modelProvider;
if (metrics.metadata?.modelName) extra.modelName = metrics.metadata.modelName;
if (metrics.sessionStartedAt !== undefined && metrics.avatarJoinedAt !== undefined) {
extra.avatarJoinLatencyMs = roundTwoDecimals(
metrics.avatarJoinedAt - metrics.sessionStartedAt,
);
}
if (metrics.playbackLatencyMs !== undefined) {
extra.playbackLatencyMs = roundTwoDecimals(metrics.playbackLatencyMs);
}
logger.child(extra).info('Avatar metrics');
}
};
127 changes: 121 additions & 6 deletions agents/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: Apache-2.0
import type {
LocalTrackPublication,
Participant,
ParticipantKind,
RemoteParticipant,
RemoteTrackPublication,
Expand Down Expand Up @@ -951,18 +953,51 @@ export async function waitForParticipant({
room,
identity,
kind,
includeLocal,
signal,
}: {
room: Room;
identity?: string;
kind?: ParticipantKind | ParticipantKind[];
includeLocal: true;
signal?: AbortSignal;
}): Promise<Participant>;
export async function waitForParticipant({
room,
identity,
kind,
includeLocal,
signal,
}: {
room: Room;
identity?: string;
kind?: ParticipantKind | ParticipantKind[];
}): Promise<RemoteParticipant> {
includeLocal?: false;
signal?: AbortSignal;
}): Promise<RemoteParticipant>;
export async function waitForParticipant({
room,
identity,
kind,
includeLocal = false,
signal,
}: {
room: Room;
identity?: string;
kind?: ParticipantKind | ParticipantKind[];
includeLocal?: boolean;
signal?: AbortSignal;
}): Promise<Participant> {
if (!room.isConnected) {
throw new Error('Room is not connected');
}
if (signal?.aborted) {
throw new Error('waitForParticipant aborted');
}

const fut = new Future<RemoteParticipant>();
const fut = new Future<Participant>();

const kindMatch = (participant: RemoteParticipant) => {
const kindMatch = (participant: Participant) => {
if (kind === undefined) return true;

if (Array.isArray(kind)) {
Expand All @@ -984,10 +1019,27 @@ export async function waitForParticipant({
fut.reject(new Error('Got disconnected from room while waiting for participant'));
};

const onAbort = () => {
if (!fut.done) {
fut.reject(new Error('waitForParticipant aborted'));
}
};

room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
room.on(RoomEvent.Disconnected, onDisconnected);
signal?.addEventListener('abort', onAbort, { once: true });

try {
const localParticipant = room.localParticipant;
if (
includeLocal &&
localParticipant &&
(identity === undefined || localParticipant.identity === identity) &&
kindMatch(localParticipant)
) {
return localParticipant;
}

for (const p of room.remoteParticipants.values()) {
onParticipantConnected(p);
if (fut.done) {
Expand All @@ -999,15 +1051,32 @@ export async function waitForParticipant({
} finally {
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
room.off(RoomEvent.Disconnected, onDisconnected);
signal?.removeEventListener('abort', onAbort);
}
}

export async function waitForTrackPublication({
room,
identity,
kind,
waitForSubscription = false,
waitForSubscription,
signal,
includeLocal,
}: {
room: Room;
identity?: string;
kind: TrackKind;
waitForSubscription?: boolean;
signal?: AbortSignal;
includeLocal: true;
}): Promise<RemoteTrackPublication | LocalTrackPublication>;
export async function waitForTrackPublication({
room,
identity,
kind,
waitForSubscription,
signal,
includeLocal,
}: {
room: Room;
/**
Expand All @@ -1029,15 +1098,31 @@ export async function waitForTrackPublication({
* publication leak listeners until the room disconnects.
*/
signal?: AbortSignal;
}): Promise<RemoteTrackPublication> {
includeLocal?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 waitForTrackPublication second overload uses includeLocal?: boolean instead of includeLocal?: false, creating a type-safety hole

The second overload of waitForTrackPublication at agents/src/utils.ts:1101 declares includeLocal?: boolean, while the analogous second overload of waitForParticipant at agents/src/utils.ts:975 correctly uses includeLocal?: false. This inconsistency creates a type-safety hole: when a caller passes a boolean variable (not a literal true), TypeScript's overload resolution skips the first overload (includeLocal: true) because boolean is not assignable to the literal true, and matches the second overload instead, returning Promise<RemoteTrackPublication>. However, the implementation may actually return a LocalTrackPublication at runtime since includeLocal is truthy.

Example showing the type hole
const flag: boolean = getIncludeLocal(); // evaluates to true
// TypeScript resolves to second overload → Promise<RemoteTrackPublication>
const pub = await waitForTrackPublication({ room, kind, includeLocal: flag });
// pub is typed as RemoteTrackPublication but could be LocalTrackPublication
pub.subscribed; // property that may not exist on LocalTrackPublication
Suggested change
includeLocal?: boolean;
includeLocal?: false;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}): Promise<RemoteTrackPublication>;
export async function waitForTrackPublication({
room,
identity,
kind,
waitForSubscription = false,
signal,
includeLocal = false,
}: {
room: Room;
identity?: string;
kind: TrackKind;
waitForSubscription?: boolean;
signal?: AbortSignal;
includeLocal?: boolean;
}): Promise<RemoteTrackPublication | LocalTrackPublication> {
if (!room.isConnected) {
throw new Error('Room is not connected');
}
if (signal?.aborted) {
throw new Error('waitForTrackPublication aborted');
}

const fut = new Future<RemoteTrackPublication>();
const fut = new Future<RemoteTrackPublication | LocalTrackPublication>();

const kindMatch = (k: TrackKind | undefined) => {
if (kind === undefined || kind === null) {
Expand Down Expand Up @@ -1077,11 +1162,24 @@ export async function waitForTrackPublication({
}
};

const onLocalTrackPublished = (publication: LocalTrackPublication | undefined) => {
if (fut.done || !publication) return;
const localParticipant = room.localParticipant;
if (localParticipant && (identity === undefined || localParticipant.identity === identity)) {
if (kindMatch(publication.kind)) {
fut.resolve(publication);
}
}
};

if (waitForSubscription) {
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
} else {
room.on(RoomEvent.TrackPublished, onTrackPublished);
}
if (includeLocal) {
room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
}

const onAbort = () => {
if (!fut.done) {
Expand All @@ -1091,6 +1189,20 @@ export async function waitForTrackPublication({
signal?.addEventListener('abort', onAbort, { once: true });

try {
const localParticipant = room.localParticipant;
if (
includeLocal &&
localParticipant &&
(identity === undefined || localParticipant.identity === identity)
) {
for (const publication of localParticipant.trackPublications.values()) {
if (kindMatch(publication.kind)) {
fut.resolve(publication);
break;
}
}
}

for (const p of room.remoteParticipants.values()) {
for (const publication of p.trackPublications.values()) {
if (matches(publication, p)) {
Expand All @@ -1108,6 +1220,9 @@ export async function waitForTrackPublication({
} else {
room.off(RoomEvent.TrackPublished, onTrackPublished);
}
if (includeLocal) {
room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
}
signal?.removeEventListener('abort', onAbort);
}
}
Expand Down
Loading
Loading