CallDeliveryReport: универсальный формат type/value и расширенные отчёты по звонкам

This commit is contained in:
AidarKC
2026-05-01 15:09:20 +03:00
parent 3061bf3d1e
commit bff403ea04
7 changed files with 281 additions and 24 deletions
+138 -9
View File
@@ -93,12 +93,25 @@ async function resolveIceServers(call) {
const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls));
const turnUsername = String(payload?.turnUsername || '').trim();
const turnPassword = String(payload?.turnPassword || '').trim();
const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : [];
const iceServers = [];
if (stunUrls.length > 0) {
iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls });
}
if (turnUrls.length > 0 && turnUsername && turnPassword) {
if (turnServers.length > 0) {
turnServers.forEach((item) => {
const urls = uniqueUrls(parseIceUrls(item?.urls));
const username = String(item?.username || '').trim();
const password = String(item?.password || '').trim();
if (urls.length === 0 || !username || !password) return;
iceServers.push({
urls: urls.length === 1 ? urls[0] : urls,
username,
credential: password,
});
});
} else if (turnUrls.length > 0 && turnUsername && turnPassword) {
iceServers.push({
urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls,
username: turnUsername,
@@ -110,7 +123,7 @@ async function resolveIceServers(call) {
await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun');
return cloneDefaultIceServers();
}
await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turn=${turnUrls.length}`);
await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turnEntries=${Math.max(0, iceServers.length - (stunUrls.length > 0 ? 1 : 0))}`);
return iceServers;
} catch (error) {
await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error));
@@ -236,6 +249,59 @@ function setActiveStatus(call) {
setStatus(call, buildActiveStatusText(call), 'active');
}
function toIsoTs(ts) {
const n = Number(ts || 0);
if (!Number.isFinite(n) || n <= 0) return '';
try { return new Date(n).toISOString(); } catch { return ''; }
}
function buildCallFactsJson(call, extra = {}) {
const pc = call?.pc || null;
const facts = {
callId: call?.callId || '',
peerLogin: call?.peerLogin || '',
remoteSessionId: call?.remoteSessionId || '',
direction: call?.direction || '',
phase: call?.phase || '',
statusText: call?.statusText || '',
startedAtMs: Number(call?.startedAtMs || 0),
startedAtIso: toIsoTs(call?.startedAtMs),
connectedAtMs: Number(call?.connectedAtMs || 0),
connectedAtIso: toIsoTs(call?.connectedAtMs),
routeLabel: call?.connectionRouteLabel || '',
routeDetails: call?.connectionRouteDetails || '',
pcConnectionState: pc?.connectionState || '',
pcIceConnectionState: pc?.iceConnectionState || '',
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
...extra,
};
try {
return JSON.stringify(facts);
} catch {
return JSON.stringify({ callId: call?.callId || '', serializeError: true });
}
}
async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) {
if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return;
try {
const valueJson = buildCallFactsJson(call, {
eventType: String(eventType || '').trim(),
eventCode: String(eventCode || '').trim(),
reason: String(reason || '').trim(),
reportedAtMs: nowMs(),
reportedAtIso: toIsoTs(nowMs()),
...extraFacts,
});
await authService.sendCallDeliveryReport({
type: String(eventType || '').trim(),
value: valueJson,
});
} catch {}
}
function cleanupTimers(call) {
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
if (call.timers?.total35s) clearTimeout(call.timers.total35s);
@@ -251,6 +317,7 @@ async function closeMedia(call) {
call.localStream = null;
call.audioSenders = [];
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
}
function stopReconnectFlow(call) {
@@ -269,7 +336,9 @@ function stopReconnectFlow(call) {
async function detectConnectionRoute(call) {
const pc = call?.pc;
if (!pc || typeof pc.getStats !== 'function') return { label: '', details: '' };
if (!pc || typeof pc.getStats !== 'function') {
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
}
try {
const stats = await pc.getStats();
let selectedPair = null;
@@ -298,7 +367,7 @@ async function detectConnectionRoute(call) {
});
}
if (!selectedPair) return { label: '', details: '' };
if (!selectedPair) return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
const local = selectedPair.localCandidateId && typeof stats.get === 'function'
? stats.get(selectedPair.localCandidateId)
@@ -309,17 +378,39 @@ async function detectConnectionRoute(call) {
const localType = String(local?.candidateType || '').trim().toLowerCase();
const remoteType = String(remote?.candidateType || '').trim().toLowerCase();
const details = `local=${localType || '-'}; remote=${remoteType || '-'}`;
const localIp = String(local?.ip || local?.address || '');
const remoteIp = String(remote?.ip || remote?.address || '');
const localPort = String(local?.port || '');
const remotePort = String(remote?.port || '');
const relayProto = String(local?.relayProtocol || remote?.relayProtocol || '');
const turnCandidateAddress = localType === 'relay'
? `${localIp}${localPort ? `:${localPort}` : ''}`
: (remoteType === 'relay' ? `${remoteIp}${remotePort ? `:${remotePort}` : ''}` : '');
const details = `local=${localType || '-'}(${localIp || '-'}${localPort ? `:${localPort}` : ''}); remote=${remoteType || '-'}(${remoteIp || '-'}${remotePort ? `:${remotePort}` : ''})`;
if (localType === 'relay' || remoteType === 'relay') {
return { label: 'через TURN', details };
const label = turnCandidateAddress
? `через TURN (${turnCandidateAddress})`
: (relayProto ? `через TURN (${relayProto})` : 'через TURN');
return { label, details, localIp, remoteIp, turnCandidateAddress };
}
if (localType || remoteType) {
return { label: 'прямое', details };
const sameLan = localIp && remoteIp && (
(localIp.startsWith('10.') && remoteIp.startsWith('10.'))
|| (localIp.startsWith('192.168.') && remoteIp.startsWith('192.168.'))
|| (localIp.startsWith('172.16.') && remoteIp.startsWith('172.16.'))
);
return {
label: sameLan ? 'напрямую в локальной сети' : 'напрямую через интернет',
details,
localIp,
remoteIp,
turnCandidateAddress: '',
};
}
return { label: '', details };
return { label: '', details, localIp, remoteIp, turnCandidateAddress: '' };
} catch {
return { label: '', details: '' };
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
}
}
@@ -336,6 +427,7 @@ function startTransportProbe(call) {
const route = await detectConnectionRoute(call);
if (!route.label || route.label === call.connectionRouteLabel) return;
call.connectionRouteLabel = route.label;
call.connectionRouteDetails = route.details || '';
setActiveStatus(call);
await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label);
};
@@ -475,6 +567,18 @@ async function finalizeCall(call, {
await emitDebug(call, 'info', 'call_finalize', `${localReasonCode}:${debugReason}`);
}
const reasonText = debugReason || localReasonCode;
if (String(localReasonCode || '') !== 'completed') {
if (call.direction === 'out') {
await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText);
} else if (call.direction === 'in') {
await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText);
}
if (String(localReasonCode || '') === 'error') {
await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText);
}
}
pushCallSummary(call, localReasonCode);
call.phase = 'ended';
@@ -565,6 +669,30 @@ async function ensurePeerConnection(call) {
setActiveStatus(call);
startTransportProbe(call);
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
if (call.direction === 'out' && !call.connectionSuccessReported) {
call.connectionSuccessReported = true;
void (async () => {
const route = await detectConnectionRoute(call);
if (route?.label) {
call.connectionRouteLabel = route.label;
}
call.connectionRouteDetails = route?.details || '';
await sendCallDeliveryReport(
call,
'call_connected',
'call_connected_success',
`connected:${route?.label || 'unknown_route'}`,
{
reportBy: 'initiator',
routeLabel: route?.label || '',
routeDetails: route?.details || '',
localIp: route?.localIp || '',
remoteIp: route?.remoteIp || '',
turnCandidateAddress: route?.turnCandidateAddress || '',
},
);
})();
}
return;
}
if (state === 'failed') {
@@ -599,6 +727,7 @@ async function ensurePeerConnection(call) {
call.localStream = stream;
call.audioSenders = [];
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
stream.getTracks().forEach((track) => {
track.enabled = track.kind === 'audio' ? !call.muted : true;
const sender = pc.addTrack(track, stream);