The hardest part of operating a browser-based dialer is not placing the call. It is knowing what actually happened.

A call touches the browser, a backend API, a database, a media server, a SIP provider and sometimes voicemail detection or AI services. Each layer can report something true and still leave the operator with a wrong conclusion.

The browser may be offline while provisioning succeeds. FreeSWITCH may have active channels while the product dashboard shows no active calls. A recording may sound clean while the agent heard choppy early media. A call may say Needs AVMD review even though nothing in live call control is blocked.

Those are not dashboard bugs in the ordinary sense. They are observability boundary bugs.

A dialer is not one system

Most web applications can define the database as the source of truth. That works for business state: users, campaigns, contacts, calls, outcomes and audit history.

It does not work for live telephony by itself.

In an outbound dialer, the database answers product questions:

  • Which agent owns the current call?
  • Which contact is being attempted?
  • Has the call reached a terminal outcome?
  • Can the agent send DTMF or drop voicemail?
  • Should the next lead be offered?

FreeSWITCH answers different questions:

  • Is the browser registered over WSS?
  • How many channels exist right now?
  • Which legs are bridged?
  • Did RTP flow on each leg?
  • Did the SIP provider reject the originate?

The browser answers another set:

  • Did the microphone start?
  • Which media constraints were actually applied?
  • Did the user hear concealed audio?
  • Did the jitter buffer grow?
  • What codec and ICE path did this peer connection use?

Then there is review state, which answers a fourth question: how trustworthy are the labels and detector decisions after the call is over?

Trying to collapse all of that into one green badge makes the interface simpler and the system less debuggable.

Product state is still the operational backbone

The product database should own lifecycle and intent. It is the durable place to say that a call was requested, assigned, answered, transferred, completed or failed.

That state has to be boring and explicit. A completed call should not be resurrected by a late CHANNEL_HANGUP. A duplicate event should not release the same agent twice. A provider-side originate failure should converge on a terminal outcome even if no normal customer channel was created.

This is where state machines and idempotent transitions pay off. Instead of treating every ESL event as a direct mutation, the backend treats events as requests to move the call through a small set of valid states.

EXAMPLE

requested       -> dialing
dialing         -> bridged
bridged         -> voicemail_handoff
voicemail_handoff -> completed
*               -> terminal

The exact names matter less than the rule: the call record owns the lifecycle. Events provide evidence. They do not get to invent an impossible state.

That product state is what the agent desk should use for workflow. It decides whether an agent can move to the next lead, whether a contact can be retried and whether the call history is complete.

But it still does not prove the media server is healthy.

PBX state answers the live runtime question

During load testing, we hit a useful distinction: the product Active calls card and the FreeSWITCH active channel count are intentionally not the same thing.

The read-only Agent Desk load path exercised API polling and SSE:

EXAMPLE

GET /agent/desk
GET /agent/events

That measured control-plane capacity: authentication, database reads, response latency, SSE stability and frontend refresh behavior.

The SIP/RTP test path was different. It launched real browser sessions, registered SIP.js over WSS and originated local FreeSWITCH echo calls. Those calls validated browser registration, INVITE/answer, ICE, DTLS, SRTP and RTP flow without touching the public SIP trunk.

In that test, the product Active calls card can stay flat while FreeSWITCH channels are very real. Operators need PBX counters for that:

EXAMPLE

outbound_dialer_freeswitch_active_channels
outbound_dialer_freeswitch_registrations

Those metrics answer: is the media server carrying live work right now?

The product database answers: is the dialer workflow carrying customer calls right now?

Both are true. They answer different questions.

Provisioning success does not prove WSS success

Another bug looked like a softphone problem from the UI:

EXAMPLE

Phone unavailable

The provisioning endpoint returned 200, so it was tempting to assume the browser had everything it needed. It did not.

Provisioning only proved that the authenticated API could return SIP credentials and connection settings. The actual WebSocket path still had to cross the proxy and reach the FreeSWITCH WSS listener.

The failing layer was lower:

EXAMPLE

/freeswitch-ws -> proxy -> internal FreeSWITCH WSS upstream

The proxy rejected the internal self-signed upstream certificate and returned 502. From the browser's point of view, SIP registration never became possible. From the API's point of view, provisioning was fine.

The fix was not a React change and not a SIP.js retry loop. It was a deployment/proxy contract problem.

That incident is the smallest example of the broader rule: a success at one boundary is not proof at the next boundary.

Browser media needs its own evidence

FreeSWITCH media stats are valuable, but they are server-side evidence. They do not prove that the agent heard clean audio in the browser.

We saw this with early media. The customer-leg recording was clean. The provider RTP stream looked healthy. But the agent heard slowed and intermittent ringback before answer.

The missing observation point was the browser.

For WebRTC calls, RTCPeerConnection.getStats() gives a different class of evidence:

  • packets received and lost;
  • jitter;
  • jitter-buffer delay;
  • concealed samples;
  • codec details;
  • RTT;
  • ICE candidate pair;
  • audio energy;
  • applied microphone settings.

That is how you distinguish "FreeSWITCH received bad media" from "FreeSWITCH emitted bad timing toward the browser" from "the browser received packets but had to conceal playback."

In the dialer, browser media telemetry needed a durable correlation handle. Attaching samples to whatever the UI believed was the active call would race with polling, registration and hangup timing.

The more reliable path was to put the call identity into the internal agent INVITE:

EXAMPLE

X-Outbound-Dialer-Call-ID: <call-id>

Then SIP.js could collect cumulative browser summaries, upload them periodically and at termination, and the backend could store one bounded row per call.

This is deliberately not raw five-second telemetry forever. Operators usually need to know whether this call had browser-side evidence, not replay an infinite stats stream.

Microphone settings are part of observability

Audio quality is not only network loss and codec choice. The browser capture pipeline matters.

For a narrowband SIP trunk, it may be tempting to force the microphone to 8 kHz because the final codec is PCMU. That can make the browser do less useful audio processing before encoding.

The better default was:

EXAMPLE

channelCount: 1
echoCancellation: true
noiseSuppression: true
autoGainControl: true

and not forcing sampleRate: 8000.

The important bit is not that these settings are universally correct. It is that the system records what the browser actually applied. Without that, "the microphone sounded bad" becomes another guess.

Review state is not call control

Answering-machine detection adds one more easy confusion.

Needs AVMD review sounds like the call is waiting for a system decision. In this architecture it means something narrower:

  • the call was answered;
  • the call ended;
  • AVMD was attempted;
  • no manual review label exists yet.

That state feeds review queues and analytics. It helps calculate coverage, precision, recall, false-positive rate and confusion matrices.

It does not change the completed call outcome. It does not requeue the contact. It does not alter voicemail-drop behavior. It does not tune the detector automatically.

That distinction matters because operators respond differently to "the system is blocked" and "our analytics need a label."

Realtime refresh can create its own outage

One of the stranger failures was a server-driven refresh loop.

The browser subscribed to live updates over SSE. The backend notified subscribers through PostgreSQL pg_notify. The intent was reasonable: when data changes, refresh the Agent Desk and admin views.

The bug was in the trigger level.

A read-like Agent Desk refresh could execute an UPDATE agents ... that affected zero rows. A statement-level trigger still emitted a notification. The notification caused the browser to refresh. The refresh executed the same zero-row update. The loop continued.

The visible symptom was simple:

EXAMPLE

GET /agent/desk
GET /admin/overview
GET /admin/csv-imports

repeating continuously.

The durable fix was not "poll less often." It was to stop publishing fake changes: move the notification trigger to row-level behavior and keep a regression test for zero-row updates.

Again, the lesson is about boundaries. A refresh mechanism is part of the runtime system. It can have feedback loops just like SIP events or retry queues.

The dashboard should expose the boundary, not hide it

For this kind of product, I want dashboards that separate questions clearly:

EXAMPLE

Product active calls
  Calls currently owned by the dialer workflow.

FreeSWITCH active channels
  Channels currently alive in the PBX runtime.

FreeSWITCH registrations
  Browser SIP endpoints currently registered over WSS.

Media stats coverage
  Completed answered calls with server-side media evidence.

Browser media coverage
  Calls with browser getStats evidence attached.

AVMD review backlog
  Answered calls that need a human label for detector analytics.

Terminal latency
  Time between source hangup/progress events and durable call finalization.

None of those is the master truth. Each one is the correct truth for a specific operational question.

The practical rule

When a realtime voice system misbehaves, avoid asking "what is the state of the call?"

Ask a sharper question:

  • What does the product lifecycle believe?
  • What does the media server have alive right now?
  • What did the browser actually receive and play?
  • What evidence exists after the call for review and analytics?
  • Which boundary changed first?

That sequence turns vague symptoms into measurable disagreements.

The goal is not to make the system perfectly observable. That does not happen in telephony. Packets disappear, browsers close, users deny microphones, carriers reject calls asynchronously and events arrive late.

The goal is to make every missing piece explicit enough that the next operator does not have to guess.