SFU integration
In an SFU, one participant’s outbound track is delivered to other participants as inbound tracks — one publisher, many subscribers. Nothing in the raw stats says which inbound track carries which published source. Establishing that link is what turns per-client symptoms into a verdict about where the fault is.
There are two independent mechanisms here:
| Remote track resolution | mediasoup router observation | |
|---|---|---|
| Data source | The clients’ own samples | The SFU’s own Router object |
| SFU support | Any topology | mediasoup only |
| Enables | Four detectors and one validator | An independent server-side sample |
| Cost | Negligible | In-memory, grows with the meeting |
Remote track resolution
Correlation is opt-in per observer: set ObserverConfig.createRemoteTrackResolver, a factory
invoked when each call is created that returns that call’s RemoteTrackResolver (or undefined).
import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from "@observertc/observer-js";
const observer = new Observer({
createRemoteTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
});RemoteTrackResolver subscribes to the bus, filtered to its call, and links tracks by publisher
id — maintaining the links directly on the track objects:
const source = inboundTrack.remoteOutboundTrack; // the publishing ObservedOutboundTrack
const receivers = [...outboundTrack.remoteInboundTracks]; // the subscribing ObservedInboundTrack[]
const orphans = observedCall.unconsumedOutboundTracks; // published, nobody subscribed
Links are kept up to date as tracks come and go.
Built-in factories
| Factory | Publisher key | Subscriber key |
|---|---|---|
createDefaultMediasoupRemoteTrackResolverFactory() | attachments.producerId | attachments.consumerId |
createP2pRemoteTrackResolverFactory() | RTP SSRC | SSRC — preserved end to end in P2P |
For the mediasoup factory, the client application puts producerId and consumerId (and
optionally direction and label) into the track attachments. See
tagging tracks.
Any other topology
The publisher id is just whatever links a subscribed track to the published one. Supply your own key resolvers:
import { Observer, RemoteTrackResolver } from "@observertc/observer-js";
const observer = new Observer({
createRemoteTrackResolver: (observedCall) =>
new RemoteTrackResolver(observedCall, {
resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
}),
});What the links unlock
IssueFanOutDetector
Does this issue follow one published source, or one receiver?
PublisherFaultCorroborationDetector
Do both ends of one track independently agree the source is at fault?
TrackDeliveryMismatchDetector
Publisher sending but every receiver dry means the forwarding path — not the camera.
UnconsumedTrackDetector
Uplink and SFU ingress spent on media nobody is receiving.
Prove the wiring
A resolver pointed at the wrong attachments field produces no links, and every detector above
correctly stays silent — which is indistinguishable from a healthy deployment. Run the
remote-track-resolver validator at start-up.
mediasoup router observation
Everything above is built from client-reported samples. When you run a mediasoup SFU you also have the server’s own ground truth — routers, transports, producers, consumers and data channels with exact lifetimes and state transitions.
ObservedMediasoupRouter captures that into a MediasoupRouterSample, completely independent
of the client sample pipeline.
The concept
You hand the observer a live mediasoup Router; it attaches to mediasoup’s own observer API and
passively tracks the router’s topology and lifecycle — no polling, no changes to your media
code:
- new transports (
webrtc/plain/pipe/direct), their selectedtuple, ICE/DTLS/SCTP state transitions andconnectedAt - producers (codec, SSRCs/RIDs,
pause/resume) and consumers (pause/resume,producerPaused/producerResumed) - data producers and data consumers
createdAt/closedAtfor every entity above
All of it lives in memory as observedRouter.sample, a plain object you own. The sample
accumulates for the life of the router: closed entities are kept with closedAt set, not
removed.
Options
observer.createObservedMediasoupRouter(settings)
| Field | Type | Required | Meaning |
|---|---|---|---|
router | mediasoup.types.Router | yes | the live router to observe |
appData | Record<string, unknown> | no | application-owned bag on the ObservedMediasoupRouter |
attachments | Record<string, unknown> | no | free-form data carried on sample.attachments |
matchPeerConnectionByWebRtcTransportId | boolean | no | opt in to peer-connection matching. Omitted / false → the event never fires |
Returns the ObservedMediasoupRouter, or undefined if the observer is closed. A router with the
same id returns the existing instance (both warn).
Useful members: .sample, .snapshot(), .appData, .attachments,
.webrtcTransportIds: Set<string>, .id, .close(), plus typed accessors
getTransportSample(id), getProducerSample(id), getConsumerSample(id),
getDataProducerSample(id), getDataConsumerSample(id).
Ordering contract — observe the router first
Create it as early as the router exists
ObservedMediasoupRouter works purely by subscribing to mediasoup’s observer API, so it can only
see events that happen after it is created. Matching is forward-only by design.
That is sufficient because the lifecycle ordering is guaranteed: a mediasoup transport is always
created on the server first, and only then can the client connect, produce/consume and start
shipping samples. So a peer-connection-added can never appear before its server-side WebRTC
transport has already been observed.
Your responsibility: call createObservedMediasoupRouter(...) before transports are added or
samples accepted. Register it later and those events are already in the past — the matches are
missed, silently.
Matching peer connections — by event, not by storage
A mediasoup WebRTC transport and a client’s RTCPeerConnection share the same id. When
matchPeerConnectionByWebRtcTransportId: true, the observer emits
mediasoup-router-matched-with-peer-connection for each matching peer connection — once per
participant’s transport — and steps back. It does not store the router on any entity.
observer.on("mediasoup-router-matched-with-peer-connection",
({ observedMediasoupRouter, observedCall, observedClient, observedPeerConnection }) => {
(observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.id);
},
);Why event-driven instead of stored on the call:
- Loose coupling. The call model stays about client telemetry; the SFU view lives on its own object and is associated only if and how you choose.
- You own the association. One router serves many peer connections across clients and calls, and the right place to keep that mapping is application-specific.
- You own the sampling. The router sample is plain in-memory state you read on your own terms.
Enriching the router sample
Every entity — router, transport, producer, consumer, data producer, data consumer — has an
attachments?: Record<string, unknown> slot. Three ways to fill it, most declarative first.
Building your own report
observedRouter.sample is live — arrays grow and history entries are appended as the router
runs, so a report built directly on it keeps changing after you think you are done. Use
snapshot() for a detached deep copy:
const report = {
...observedRouter.snapshot(), // never moves again
generatedAt: Date.now(),
region: process.env.REGION,
};Custom data belongs in attachments
The sample types do not carry a Record<string, unknown> index signature. That signature allowed
arbitrary top-level keys but also silently accepted typos on real fields and weakened autocomplete.
If you were assigning ad-hoc keys directly onto a sample object, move them into attachments.
Memory and large meetings
Nothing is evicted for you
This is intentionally the simplest possible approach — everything is in memory, nothing is sampled or evicted. That is fine for typical rooms, but the cost at scale is real:
- Consumers grow as O(N²) on a single flat router: with
Nparticipants each producing audio + video and consuming everyone else, the sample holds roughly2·N·(N−1)consumer records — about 19 800 atN= 100. - The sample is cumulative — closed entities and their
historyare retained — so it also grows with call duration and churn (renegotiation, simulcast layer changes, rejoins).
A 100-participant flat router can reach tens of megabytes and keep growing. There is no built-in
sink, snapshotting or eviction, by design. If you run large meetings, do your own sampling: on
your own cadence read observedRouter.sample, persist what you need, drop the rest, and close
routers you no longer track. (mediasoup typically shards routers across workers, which keeps any
one router small.)
const timer = setInterval(() => persist(observedRouter.snapshot()), 10_000);
observer.on("mediasoup-router-removed", ({ observedMediasoupRouter }) => {
clearInterval(timer);
persist(observedMediasoupRouter.sample); // final state, closedAt set
});End-to-end example
import { Observer } from "@observertc/observer-js";
import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from "@observertc/observer-js";
const observer = new Observer();
// 1) Observe the SFU side FIRST — as early as the router exists.
const observedRouter = observer.createObservedMediasoupRouter({
router,
matchPeerConnectionByWebRtcTransportId: true,
enrich: {
producer: (p) => ({ participantId: p.appData.participantId }),
},
});
// 2) Feed client samples as usual.
transport.on("sample", (sample) => observer.accept(sample));
// 3) Every peer connection whose id matches a router transport id fires this.
observer.on("mediasoup-router-matched-with-peer-connection",
({ observedMediasoupRouter, observedCall, observedPeerConnection }:
ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
(observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.id);
},
);
// 4) The router closed — persist the final state, drop your reference.
observer.on("mediasoup-router-removed", ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
persist(observedMediasoupRouter.sample);
});