Calls: quality and problem report endpoints, tags and tokens in the hub (spec 15.26), WIP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+950
−6
cmd/calls/main.go+24 −1internal/calls/app.go+16 −0internal/calls/config.go+14 −1internal/calls/hub/hub.go+18 −1internal/calls/hub/relay.go+3 −1internal/calls/hub/report.go+159 −0internal/calls/quality.go+477 −0internal/calls/ratelimit.go+6 −0internal/calls/stream.go+2 −2internal/callstest/browser_test.go+36 −0protocol/messages.go+4 −0protocol/quality.go+191 −0
diff --git a/cmd/calls/main.go b/cmd/calls/main.go| index 085cf42..1cd452d 100644 |
| --- a/cmd/calls/main.go |
| +++ b/cmd/calls/main.go |
| @@ -3,10 +3,12 @@ package main |
| import ( |
| "context" |
| "fmt" |
| + "io" |
| "log/slog" |
| "os" |
| "os/signal" |
| "strings" |
| + "sync" |
| "syscall" |
| _ "time/tzdata" // the browser's zone for a call's title, wherever the host has no zoneinfo |
| @@ -19,7 +21,14 @@ import ( |
| ) |
| func main() { |
| - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| + // One writer for both handlers, so a text line and a JSON line are |
| + // never interleaved mid-write. |
| + stderr := &lockedWriter{w: os.Stderr} |
| + logger := slog.New(slog.NewTextHandler(stderr, nil)) |
| + // Quality and problem reports are JSON lines (spec §15.26): the |
| + // platform's `carlos logs --field` reads fields only from a line that |
| + // is a JSON object. |
| + reportLogger := slog.New(slog.NewJSONHandler(stderr, nil)) |
| // -env is stripped before Resolve sees argv, because Resolve parses |
| // os.Args itself for -socket, -addr and -db and a second flag set |
| @@ -63,6 +72,8 @@ func main() { |
| Relay: os.Getenv("CARLOS_RELAY"), |
| Zone: os.Getenv("CALLS_ZONE"), |
| Logger: logger, |
| + QualityReports: os.Getenv("CALLS_QUALITY_REPORTS"), |
| + ReportLogger: reportLogger, |
| } |
| // Cancelled on SIGTERM, which is how a live SSE stream learns the |
| @@ -158,3 +169,15 @@ func extractEnvFlag(args []string) (env string, rest []string, err error) { |
| } |
| return env, rest, nil |
| } |
| + |
| +// lockedWriter serializes writes from the two log handlers. |
| +type lockedWriter struct { |
| + mu sync.Mutex |
| + w io.Writer |
| +} |
| + |
| +func (l *lockedWriter) Write(p []byte) (int, error) { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.w.Write(p) |
| +} |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index a359d81..0f1de31 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -55,6 +55,14 @@ type app struct { |
| // apiLimiter is the drive surface's per-agent token bucket (spec |
| // §8): sixty requests a minute, burst sixty, keyed "agent:<id>". |
| apiLimiter *rateLimiter |
| + // qualityLimiter and problemLimiter are the report endpoints' (spec |
| + // §15.26), keyed on the reporting participation's tag: a quality |
| + // report every ten seconds, burst six; a problem report a minute, |
| + // burst three. |
| + qualityLimiter *rateLimiter |
| + problemLimiter *rateLimiter |
| + // reportLog is where the reports go: Config.ReportLogger. |
| + reportLog *slog.Logger |
| // tickPass is the housekeeping pass tickLoop runs, nil in production |
| // and everywhere but the one test that records what deadline the |
| @@ -411,6 +419,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| }) |
| a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) |
| a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets) |
| + a.qualityLimiter = newRateLimiterEvery(10*time.Second, 6, maxReportBuckets) |
| + a.problemLimiter = newRateLimiterEvery(time.Minute, 3, maxReportBuckets) |
| + a.reportLog = rc.ReportLogger |
| + if !rc.qualityReports { |
| + rc.Logger.Info("calls: quality reports off (CALLS_QUALITY_REPORTS=off)") |
| + } |
| writer, err := d.G.DB() |
| if err != nil { |
| @@ -537,6 +551,8 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/m/{sqid}/stream", a.stream) |
| r.Post("/m/{sqid}/signal", a.signal) |
| r.Get("/m/{sqid}/ice", a.ice) |
| + r.Post("/m/{sqid}/quality", a.quality) |
| + r.Post("/m/{sqid}/problem", a.problem) |
| }) |
| }) |
diff --git a/internal/calls/config.go b/internal/calls/config.go| index 5903f98..6f93536 100644 |
| --- a/internal/calls/config.go |
| +++ b/internal/calls/config.go |
| @@ -75,6 +75,15 @@ type Config struct { |
| // version one. |
| Zone string |
| Logger *slog.Logger |
| + // QualityReports is CALLS_QUALITY_REPORTS: "off" turns the automatic |
| + // quality reports off (spec §15.26), and the endpoint answers 204 so |
| + // clients stop sending. Anything else, unset included, is on. |
| + QualityReports string |
| + // ReportLogger is where quality and problem reports are written. The |
| + // deployed main hands it a JSON handler, because the platform's |
| + // `carlos logs --field` reads fields only from JSON lines. Nil is |
| + // Logger. |
| + ReportLogger *slog.Logger |
| } |
| func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| @@ -96,13 +105,17 @@ type resolved struct { |
| relay []relay.Region |
| relayConfigured bool |
| zone *time.Location |
| + qualityReports bool |
| } |
| func resolve(cfg Config) (resolved, error) { |
| if cfg.Logger == nil { |
| cfg.Logger = slog.Default() |
| } |
| - out := resolved{Config: cfg} |
| + out := resolved{Config: cfg, qualityReports: qualityReportsOn(cfg.QualityReports)} |
| + if out.ReportLogger == nil { |
| + out.ReportLogger = cfg.Logger |
| + } |
| if strings.TrimSpace(cfg.Origin) == "" { |
| if !cfg.development() { |
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go| index 59c3619..5a6bd2e 100644 |
| --- a/internal/calls/hub/hub.go |
| +++ b/internal/calls/hub/hub.go |
| @@ -109,6 +109,11 @@ type Participation struct { |
| lastSeen map[PeerID]uint64 |
| ring []Message // targeted messages delivered to this peer, newest last, cap RingSize |
| evicted map[PeerID]uint64 // highest seq per sender the ring has dropped |
| + // peerTag and reportToken are spec §15.26's: the random tag this |
| + // participation's reports are logged under, and the token its client |
| + // reports with (report.go). |
| + peerTag string |
| + reportToken string |
| } |
| type pairKey struct{ a, b PeerID } |
| @@ -125,6 +130,9 @@ type room struct { |
| peers map[PeerID]*Participation |
| gens map[pairKey]uint64 |
| ended bool |
| + // call is the call instance now running here (report.go): replaced |
| + // when a participation opens in a room that held nobody. |
| + call *callInstance |
| } |
| // tombstone remembers why a participation ended, for ten minutes, so a |
| @@ -148,6 +156,7 @@ type Hub struct { |
| bySession map[string]map[string]PeerID // room -> session -> peer |
| byPerson map[int64]int // open streams per person |
| tombstones map[PeerID]tombstone |
| + reports map[string]*reportGrant // report token -> grant (report.go) |
| } |
| func New(cfg Config) *Hub { |
| @@ -161,6 +170,7 @@ func New(cfg Config) *Hub { |
| cfg: cfg, boot: randomID(), |
| rooms: map[string]*room{}, bySession: map[string]map[string]PeerID{}, |
| byPerson: map[int64]int{}, tombstones: map[PeerID]tombstone{}, |
| + reports: map[string]*reportGrant{}, |
| } |
| } |
| @@ -190,6 +200,9 @@ type OpenResult struct { |
| Peer PeerID |
| Sub *Sub |
| Existing PeerID |
| + // ReportToken is the client's authority to send quality and problem |
| + // reports (spec §15.26), carried in its hello. |
| + ReportToken string |
| } |
| // Open is a fresh stream: a new unjoined participation, subject to the |
| @@ -229,13 +242,15 @@ func (h *Hub) Open(sqid, session string, personID int64, name string, kind Kind) |
| Peer: PeerID(randomID()), Room: sqid, Session: session, PersonID: personID, Name: name, Kind: kind, |
| OpenedAt: h.cfg.Now(), sub: newSub(), seqOut: map[PeerID]uint64{}, lastSeen: map[PeerID]uint64{}, |
| } |
| + h.enrolReportLocked(r, p) // before p joins r.peers: an empty room starts a new call |
| + |
| r.peers[p.Peer] = p |
| if h.bySession[sqid] == nil { |
| h.bySession[sqid] = map[string]PeerID{} |
| } |
| h.bySession[sqid][session] = p.Peer |
| h.byPerson[personID]++ |
| - return OpenResult{Peer: p.Peer, Sub: p.sub}, nil |
| + return OpenResult{Peer: p.Peer, Sub: p.sub, ReportToken: p.reportToken}, nil |
| } |
| // Find answers a peer only through its own room and session. |
| @@ -382,6 +397,7 @@ func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted, |
| p.sub = nil |
| } |
| delete(r.peers, p.Peer) |
| + h.endReportLocked(p) |
| if h.bySession[r.sqid][p.Session] == p.Peer { |
| delete(h.bySession[r.sqid], p.Session) |
| } |
| @@ -432,6 +448,7 @@ func (h *Hub) Tick(ctx context.Context) { |
| delete(h.tombstones, id) |
| } |
| } |
| + h.sweepReportsLocked(now) |
| var checks []standingCheck |
| for _, r := range h.rooms { |
| for _, p := range r.peers { |
diff --git a/internal/calls/hub/relay.go b/internal/calls/hub/relay.go| index 7e5cdc7..9745e7b 100644 |
| --- a/internal/calls/hub/relay.go |
| +++ b/internal/calls/hub/relay.go |
| @@ -131,6 +131,8 @@ type ResumeResult struct { |
| Roster *Roster |
| Replay []Message |
| Gap []PeerID |
| + // ReportToken is the participation's, for the resumed hello (§15.26). |
| + ReportToken string |
| } |
| // ResumeError is a refused resume in the shape spec §4.5's table asks |
| @@ -246,7 +248,7 @@ func (h *Hub) Resume(ctx context.Context, sqid, session string, peer PeerID, las |
| } |
| p.sub = newSub() |
| p.resumeUntil = time.Time{} |
| - res := ResumeResult{Sub: p.sub, Admitted: p.Admitted} |
| + res := ResumeResult{Sub: p.sub, Admitted: p.Admitted, ReportToken: p.reportToken} |
| if !p.Admitted { |
| // A waiting guest learns nothing by resuming (ruling 9). |
| return res, nil |
diff --git a/internal/calls/hub/report.go b/internal/calls/hub/report.go| new file mode 100644 |
| index 0000000..6530ed3 |
| --- /dev/null |
| +++ b/internal/calls/hub/report.go |
| @@ -0,0 +1,159 @@ |
| +package hub |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/base32" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Quality and problem reports (spec §15.26, 2026-10-03). |
| +// |
| +// A report is written to the app log under two random tags and nothing |
| +// that names the call or the people in it: a call tag, minted when a room |
| +// that was empty gains a participation, so every line from one call shares |
| +// it; and a peer tag, minted per participation. Neither is derived from |
| +// the sqid, the peer id, the person or the session, and neither is ever |
| +// logged beside any of them, so a log line cannot be walked back to a |
| +// meeting or a person. They live here, in memory, and die with the |
| +// process like everything else in the hub. |
| +// |
| +// A client is handed a report token in its hello. It is the client's |
| +// authority to report: for the life of the participation, and for |
| +// ReportAfterLeave after it ends, so "Report a problem" still works on the |
| +// screen that says the call is over. |
| + |
| +const ( |
| + // ReportAfterLeave is how long a report token outlives its |
| + // participation. |
| + ReportAfterLeave = 10 * time.Minute |
| + // maxReportGrants bounds the token table: every live participation |
| + // (at most sixteen to a room) plus those ended in the last ten |
| + // minutes. A real deployment holds tens; past this, the oldest ended |
| + // grant is dropped first. |
| + maxReportGrants = 4096 |
| + // maxCallPeers bounds how many participations one call instance |
| + // remembers a tag for. A remote peer past it is reported as unknown. |
| + maxCallPeers = 64 |
| +) |
| + |
| +// callInstance is one call: a room from the moment it stopped being empty |
| +// to the moment it is empty again. |
| +type callInstance struct { |
| + tag string |
| + peers map[PeerID]string // peer id -> peer tag, for every participation this call has held |
| +} |
| + |
| +type reportGrant struct { |
| + room string |
| + session string |
| + call *callInstance |
| + peerTag string |
| + endedAt time.Time // zero while the participation lives |
| +} |
| + |
| +// Reporter is what a report token authorizes: the tags to log under, |
| +// and the tags of everyone this call has held, by peer id, so a report |
| +// that names a remote peer by its id is logged under that peer's tag. |
| +type Reporter struct { |
| + CallTag string |
| + PeerTag string |
| + Live bool |
| + Peers map[PeerID]string |
| +} |
| + |
| +// newTag is a random tag: ten bytes in lowercase base32, sixteen |
| +// characters, from crypto/rand. Not a hash of anything. |
| +func newTag() string { |
| + b := make([]byte, 10) |
| + if _, err := rand.Read(b); err != nil { |
| + panic(err) |
| + } |
| + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) |
| +} |
| + |
| +// enrolReportLocked gives a fresh participation its peer tag and report |
| +// token, starting a new call instance when the room held nobody. |
| +func (h *Hub) enrolReportLocked(r *room, p *Participation) { |
| + if r.call == nil || len(r.peers) == 0 { |
| + r.call = &callInstance{tag: newTag(), peers: map[PeerID]string{}} |
| + } |
| + p.peerTag = newTag() |
| + p.reportToken = randomID() |
| + if len(r.call.peers) < maxCallPeers { |
| + r.call.peers[p.Peer] = p.peerTag |
| + } |
| + if len(h.reports) >= maxReportGrants { |
| + h.evictReportLocked() |
| + } |
| + h.reports[p.reportToken] = &reportGrant{room: r.sqid, session: p.Session, call: r.call, peerTag: p.peerTag} |
| +} |
| + |
| +// endReportLocked starts a participation's after-leave window. |
| +func (h *Hub) endReportLocked(p *Participation) { |
| + if g, ok := h.reports[p.reportToken]; ok && g.endedAt.IsZero() { |
| + g.endedAt = h.cfg.Now() |
| + } |
| +} |
| + |
| +// sweepReportsLocked drops grants whose after-leave window has passed. |
| +func (h *Hub) sweepReportsLocked(now time.Time) { |
| + for token, g := range h.reports { |
| + if !g.endedAt.IsZero() && now.Sub(g.endedAt) > ReportAfterLeave { |
| + delete(h.reports, token) |
| + } |
| + } |
| +} |
| + |
| +// evictReportLocked makes room: the grant that ended longest ago, or, if |
| +// none has ended, nothing — a live participation's token is never taken |
| +// from it, and the table is then bounded by the participations |
| +// themselves. |
| +func (h *Hub) evictReportLocked() { |
| + var oldest string |
| + var at time.Time |
| + for token, g := range h.reports { |
| + if g.endedAt.IsZero() { |
| + continue |
| + } |
| + if oldest == "" || g.endedAt.Before(at) { |
| + oldest, at = token, g.endedAt |
| + } |
| + } |
| + if oldest != "" { |
| + delete(h.reports, oldest) |
| + } |
| +} |
| + |
| +// ReportTokenFor is the token of the participation this session holds in |
| +// the room under peer, for a resumed stream's hello. |
| +func (h *Hub) ReportTokenFor(sqid, session string, peer PeerID) string { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + p, ok := h.findLocked(sqid, session, peer) |
| + if !ok { |
| + return "" |
| + } |
| + return p.reportToken |
| +} |
| + |
| +// Reporter answers a report token, presented by this session for this |
| +// room: the participation's while it lives, and for ReportAfterLeave |
| +// after it ends. Anything else is not found. |
| +func (h *Hub) Reporter(sqid, session, token string) (Reporter, bool) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + g, ok := h.reports[token] |
| + if !ok || g.room != sqid || g.session != session { |
| + return Reporter{}, false |
| + } |
| + if !g.endedAt.IsZero() && h.cfg.Now().Sub(g.endedAt) > ReportAfterLeave { |
| + delete(h.reports, token) |
| + return Reporter{}, false |
| + } |
| + peers := make(map[PeerID]string, len(g.call.peers)) |
| + for id, tag := range g.call.peers { |
| + peers[id] = tag |
| + } |
| + return Reporter{CallTag: g.call.tag, PeerTag: g.peerTag, Live: g.endedAt.IsZero(), Peers: peers}, true |
| +} |
diff --git a/internal/calls/quality.go b/internal/calls/quality.go| new file mode 100644 |
| index 0000000..a0d3776 |
| --- /dev/null |
| +++ b/internal/calls/quality.go |
| @@ -0,0 +1,477 @@ |
| +package calls |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "math" |
| + "net/http" |
| + "reflect" |
| + "regexp" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/calls/internal/calls/hub" |
| + "amadan.net/oficina/calls/protocol" |
| +) |
| + |
| +// Quality and problem reports, spec §15.26 (Paul's ruling, 2026-10-03). |
| +// |
| +// Two endpoints, both authorized by the report token a client was given |
| +// in its hello (hub/report.go), presented by the session it was given to, |
| +// for the room it was given for — during the participation and for ten |
| +// minutes after it ends: |
| +// |
| +// - POST /m/{sqid}/quality: the automatic report, every thirty seconds |
| +// and once at leave. A fixed schema (protocol/quality.go), validated |
| +// strictly, re-encoded from what was validated, and written as one |
| +// `calls: quality` line. CALLS_QUALITY_REPORTS=off answers 204 before |
| +// anything else is looked at, and a client that is answered 204 stops |
| +// sending for the rest of the call. |
| +// - POST /m/{sqid}/problem: "Report a problem". The person's note and |
| +// their client's call diagnostics, written as one `calls: problem |
| +// report` line and as many `calls: problem report diagnostics` lines as |
| +// the diagnostics need, under a reference the client shows. |
| +// |
| +// Neither touches the database: the log is the only store. Neither log |
| +// line carries the meeting's sqid, a peer id, a person, an email, a name |
| +// or an address; the call and the participation are named only by the |
| +// hub's random tags. |
| + |
| +const ( |
| + // reportTokenHeader carries the token: a header rather than a body |
| + // member so the body stays exactly the schema. |
| + reportTokenHeader = "X-Calls-Report" |
| + // maxReportBuckets bounds both report limiters' maps, as |
| + // maxSignalBuckets does the signal limiter's. |
| + maxReportBuckets = 4096 |
| + // diagnosticsChunk is how much escaped diagnostics text goes in one |
| + // log line. The platform keeps 8 KB of a line and truncates the rest |
| + // (carlos/platform internal/activator/backend_exec.go execLogLineMax), |
| + // so a chunk leaves room for the line's own fields. |
| + diagnosticsChunk = 6000 |
| +) |
| + |
| +var ( |
| + reportTokenPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| + versionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$`) |
| + numericVersion = regexp.MustCompile(`^[0-9]{1,4}([._][0-9]{1,6}){0,3}$`) |
| + |
| + reportApps = []string{"web", "mac", "iphone", "ipad"} |
| + reportBrowsers = []string{"chrome", "safari", "firefox", "edge", "other"} |
| + reportOSes = []string{"macos", "ios", "ipados", "windows", "linux", "android", "chromeos", "other"} |
| +) |
| + |
| +// qualityReportsOn is the kill switch: CALLS_QUALITY_REPORTS=off turns the |
| +// automatic reports off. Anything else, including unset, is on. |
| +func qualityReportsOn(v string) bool { |
| + return !strings.EqualFold(strings.TrimSpace(v), "off") |
| +} |
| + |
| +// reporter resolves the request's report token, or answers 404 itself. |
| +func (a *app) reporter(w http.ResponseWriter, r *http.Request) (hub.Reporter, string, bool) { |
| + sqid := chi.URLParam(r, "sqid") |
| + token := r.Header.Get(reportTokenHeader) |
| + session, ok := a.sessionKey(r) |
| + if !ok || !validSqid(sqid) || !reportTokenPattern.MatchString(token) { |
| + streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"}) |
| + return hub.Reporter{}, "", false |
| + } |
| + rep, ok := a.hub.Reporter(sqid, session, token) |
| + if !ok { |
| + streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"}) |
| + return hub.Reporter{}, "", false |
| + } |
| + return rep, sqid, true |
| +} |
| + |
| +// readReport reads a body up to limit, answering 413 or 400 itself. |
| +func readReport(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, bool) { |
| + r.Body = http.MaxBytesReader(w, r.Body, limit) |
| + raw, err := io.ReadAll(r.Body) |
| + if err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + streamJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"}) |
| + } else { |
| + streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) |
| + } |
| + return nil, false |
| + } |
| + return raw, true |
| +} |
| + |
| +func rateRefused(w http.ResponseWriter, retry time.Duration) { |
| + w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) |
| + streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"}) |
| +} |
| + |
| +// quality is POST /m/{sqid}/quality. |
| +func (a *app) quality(w http.ResponseWriter, r *http.Request) { |
| + if !a.cfg.qualityReports { |
| + w.WriteHeader(http.StatusNoContent) |
| + return |
| + } |
| + rep, _, ok := a.reporter(w, r) |
| + if !ok { |
| + return |
| + } |
| + if allowed, retry := a.qualityLimiter.allow(rep.PeerTag, a.store.now()); !allowed { |
| + rateRefused(w, retry) |
| + return |
| + } |
| + raw, ok := readReport(w, r, protocol.MaxQualityBody) |
| + if !ok { |
| + return |
| + } |
| + q, ok := validateQuality(raw) |
| + if !ok { |
| + streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) |
| + return |
| + } |
| + a.logQuality(rep, q) |
| + w.WriteHeader(http.StatusAccepted) |
| +} |
| + |
| +// problem is POST /m/{sqid}/problem. |
| +func (a *app) problem(w http.ResponseWriter, r *http.Request) { |
| + rep, sqid, ok := a.reporter(w, r) |
| + if !ok { |
| + return |
| + } |
| + if allowed, retry := a.problemLimiter.allow(rep.PeerTag, a.store.now()); !allowed { |
| + rateRefused(w, retry) |
| + return |
| + } |
| + raw, ok := readReport(w, r, protocol.MaxProblemBody) |
| + if !ok { |
| + return |
| + } |
| + p, ok := validateProblem(raw) |
| + if !ok { |
| + streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) |
| + return |
| + } |
| + ref := newReportRef() |
| + a.logProblem(rep, sqid, ref, p) |
| + streamJSON(w, http.StatusOK, map[string]any{"ref": ref}) |
| +} |
| + |
| +// validateQuality is the strict half: the schema's types (a mistyped |
| +// member fails the decode), its enums, its bounds and its version. A |
| +// member the schema does not name is ignored, never logged: the log line |
| +// is re-encoded from this value, not from the request's bytes. |
| +func validateQuality(raw []byte) (protocol.QualityReport, bool) { |
| + var q protocol.QualityReport |
| + if !utf8.Valid(raw) || json.Unmarshal(raw, &q) != nil { |
| + return q, false |
| + } |
| + if q.V != protocol.QualityVersion || q.Seconds == nil || *q.Seconds <= 0 { |
| + return q, false |
| + } |
| + if !validClient(&q.Client) { |
| + return q, false |
| + } |
| + if d := q.Devices; d != nil && (!enumOrEmpty(d.Input, protocol.DeviceKinds) || !enumOrEmpty(d.Output, protocol.DeviceKinds)) { |
| + return q, false |
| + } |
| + if len(q.Peers) > protocol.MaxQualityPeers { |
| + return q, false |
| + } |
| + seen := map[protocol.PeerID]bool{} |
| + for i := range q.Peers { |
| + p := &q.Peers[i] |
| + if !validPeerID(p.Peer) || seen[p.Peer] { |
| + return q, false |
| + } |
| + seen[p.Peer] = true |
| + if p.Path != nil && (!enumOrEmpty(p.Path.Local, protocol.CandidateKinds) || |
| + !enumOrEmpty(p.Path.Remote, protocol.CandidateKinds) || !enumOrEmpty(p.Path.Protocol, protocol.PathProtocols)) { |
| + return q, false |
| + } |
| + if p.Out != nil { |
| + for _, s := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} { |
| + if s != nil && !enumOrEmpty(s.Limit, protocol.QualityLimits) { |
| + return q, false |
| + } |
| + } |
| + } |
| + } |
| + // Every number, wherever it is: finite, non-negative, under its |
| + // ceiling, and rounded to a tenth for the line. |
| + if !boundNumbers(reflect.ValueOf(&q).Elem(), "") { |
| + return q, false |
| + } |
| + return q, true |
| +} |
| + |
| +func validClient(c *protocol.ReportClient) bool { |
| + if !slices.Contains(reportApps, c.App) { |
| + return false |
| + } |
| + if c.Version != "" && !versionPattern.MatchString(c.Version) { |
| + return false |
| + } |
| + if !enumOrEmpty(c.Browser, reportBrowsers) || !enumOrEmpty(c.OS, reportOSes) { |
| + return false |
| + } |
| + if c.BrowserVersion != "" && !numericVersion.MatchString(c.BrowserVersion) { |
| + return false |
| + } |
| + if c.OSVersion != "" && !numericVersion.MatchString(c.OSVersion) { |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +func enumOrEmpty(v string, allowed []string) bool { return v == "" || slices.Contains(allowed, v) } |
| + |
| +// numberCeiling is a number's bound, by its JSON name. |
| +func numberCeiling(name string) float64 { |
| + switch name { |
| + case "seconds", "reconnectingSeconds", "cantHearSeconds", "freezeSeconds": |
| + return protocol.MaxQualitySeconds |
| + case "rtt", "jitter", "jitterBufferMs": |
| + return protocol.MaxQualityMillis |
| + case "kbps": |
| + return protocol.MaxQualityKbps |
| + case "width", "height": |
| + return protocol.MaxQualityPixels |
| + case "fps": |
| + return protocol.MaxQualityFPS |
| + } |
| + return protocol.MaxQualityCount |
| +} |
| + |
| +// boundNumbers walks the report and checks every *float64 against its |
| +// ceiling, rounding each to one decimal place. |
| +func boundNumbers(v reflect.Value, name string) bool { |
| + switch v.Kind() { |
| + case reflect.Pointer: |
| + if v.IsNil() { |
| + return true |
| + } |
| + if f, ok := v.Interface().(*float64); ok { |
| + x := *f |
| + if math.IsNaN(x) || math.IsInf(x, 0) || x < 0 || x > numberCeiling(name) { |
| + return false |
| + } |
| + *f = math.Round(x*10) / 10 |
| + return true |
| + } |
| + return boundNumbers(v.Elem(), name) |
| + case reflect.Struct: |
| + t := v.Type() |
| + for i := 0; i < v.NumField(); i++ { |
| + tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") |
| + if !boundNumbers(v.Field(i), tag) { |
| + return false |
| + } |
| + } |
| + case reflect.Slice: |
| + for i := 0; i < v.Len(); i++ { |
| + if !boundNumbers(v.Index(i), name) { |
| + return false |
| + } |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// validateProblem bounds the note (characters) and the whole body (bytes, |
| +// by the reader), and the client as the quality report does. |
| +func validateProblem(raw []byte) (protocol.ProblemReport, bool) { |
| + var p protocol.ProblemReport |
| + if !utf8.Valid(raw) || json.Unmarshal(raw, &p) != nil { |
| + return p, false |
| + } |
| + if utf8.RuneCountInString(p.Note) > protocol.MaxProblemNote || !validClient(&p.Client) { |
| + return p, false |
| + } |
| + return p, true |
| +} |
| + |
| +// logQuality writes the one `calls: quality` line. The top level carries |
| +// what the platform can filter on (carlos logs --field): the tags, the |
| +// client, and a few headline numbers; `report` is the rest, as validated, |
| +// with each remote peer's id replaced by its tag. |
| +func (a *app) logQuality(rep hub.Reporter, q protocol.QualityReport) { |
| + sum := summarize(q) |
| + for i := range q.Peers { |
| + q.Peers[i].Peer = protocol.PeerID(tagOrUnknown(rep, q.Peers[i].Peer)) |
| + } |
| + body := struct { |
| + Seconds *float64 `json:"seconds"` |
| + Final bool `json:"final,omitempty"` |
| + Devices *protocol.Devices `json:"devices,omitempty"` |
| + Events *protocol.CallEvents `json:"events,omitempty"` |
| + Peers []protocol.PeerQuality `json:"peers,omitempty"` |
| + }{q.Seconds, q.Final, q.Devices, q.Events, q.Peers} |
| + attrs := append(clientAttrs(rep, q.Client), |
| + slog.Bool("final", q.Final), |
| + slog.Float64("seconds", *q.Seconds), |
| + slog.Int("remotes", len(q.Peers)), |
| + slog.Bool("relay", sum.relay), |
| + slog.Float64("maxLossPct", sum.lossPct), |
| + slog.Float64("maxRtt", sum.rtt), |
| + slog.Float64("freezes", sum.freezes), |
| + slog.Float64("reconnecting", sum.reconnecting), |
| + slog.Any("report", body), |
| + ) |
| + a.reportLog.LogAttrs(context.Background(), slog.LevelInfo, "calls: quality", attrs...) |
| +} |
| + |
| +func clientAttrs(rep hub.Reporter, c protocol.ReportClient) []slog.Attr { |
| + attrs := []slog.Attr{slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), slog.String("app", c.App)} |
| + for _, kv := range [][2]string{{"version", c.Version}, {"browser", c.Browser}, {"browserVersion", c.BrowserVersion}, {"os", c.OS}, {"osVersion", c.OSVersion}} { |
| + if kv[1] != "" { |
| + attrs = append(attrs, slog.String(kv[0], kv[1])) |
| + } |
| + } |
| + return attrs |
| +} |
| + |
| +func tagOrUnknown(rep hub.Reporter, id protocol.PeerID) string { |
| + if tag, ok := rep.Peers[id]; ok { |
| + return tag |
| + } |
| + return "unknown" |
| +} |
| + |
| +type qualitySummary struct { |
| + relay bool |
| + lossPct, rtt, freezes, reconnecting float64 |
| +} |
| + |
| +// summarize is the line's headline: whether any connection went through |
| +// the relay, the worst inbound loss as a percentage of packets, the worst |
| +// round trip, the freezes and the "Reconnecting…" notices. |
| +func summarize(q protocol.QualityReport) qualitySummary { |
| + var s qualitySummary |
| + val := func(p *float64) float64 { |
| + if p == nil { |
| + return 0 |
| + } |
| + return *p |
| + } |
| + loss := func(packets, lost *float64) { |
| + n, l := val(packets), val(lost) |
| + if n+l > 0 { |
| + s.lossPct = max(s.lossPct, math.Round(1000*l/(n+l))/10) |
| + } |
| + } |
| + if q.Events != nil { |
| + s.reconnecting += val(q.Events.Reconnecting) |
| + } |
| + for _, p := range q.Peers { |
| + if p.Path != nil && (p.Path.Local == "relay" || p.Path.Remote == "relay") { |
| + s.relay = true |
| + } |
| + s.rtt = max(s.rtt, val(p.RTT)) |
| + if p.In != nil { |
| + if p.In.Audio != nil { |
| + loss(p.In.Audio.Packets, p.In.Audio.Lost) |
| + } |
| + for _, v := range []*protocol.InVideo{p.In.Video, p.In.Screen} { |
| + if v != nil { |
| + loss(v.Packets, v.Lost) |
| + s.freezes += val(v.Freezes) |
| + } |
| + } |
| + } |
| + if p.Out != nil { |
| + for _, o := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} { |
| + if o != nil { |
| + s.rtt = max(s.rtt, val(o.RTT)) |
| + } |
| + } |
| + } |
| + if p.Events != nil { |
| + s.reconnecting += val(p.Events.Reconnecting) |
| + } |
| + } |
| + return s |
| +} |
| + |
| +// logProblem writes the report: one head line with the note, and the |
| +// diagnostics in as many lines as they take, each under the reference. |
| +// |
| +// The diagnostics are the client's own text, and are scrubbed here of |
| +// what the client could not know to leave out: every peer id this call |
| +// has held, in full or as the six-character prefix the diagnostics use, |
| +// becomes "peer:<tag>", and the meeting's sqid becomes "[meeting]". A |
| +// client already writes "peer abc123" for a person instead of their |
| +// name; device names stay, which the spec says. |
| +func (a *app) logProblem(rep hub.Reporter, sqid, ref string, p protocol.ProblemReport) { |
| + scrub := func(s string) string { |
| + s = strings.ReplaceAll(s, sqid, "[meeting]") |
| + for id, tag := range rep.Peers { |
| + s = strings.ReplaceAll(s, string(id), "peer:"+tag) |
| + } |
| + for id, tag := range rep.Peers { |
| + s = strings.ReplaceAll(s, string(id)[:6], "peer:"+tag) |
| + } |
| + return s |
| + } |
| + note := scrub(p.Note) |
| + chunks := chunkEscaped(scrub(p.Diagnostics), diagnosticsChunk) |
| + attrs := append([]slog.Attr{slog.String("ref", ref)}, clientAttrs(rep, p.Client)...) |
| + attrs = append(attrs, slog.Bool("live", rep.Live), slog.String("note", note), slog.Int("parts", len(chunks))) |
| + a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report", attrs...) |
| + for i, c := range chunks { |
| + a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report diagnostics", |
| + slog.String("ref", ref), slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), |
| + slog.Int("part", i+1), slog.Int("of", len(chunks)), slog.String("text", c)) |
| + } |
| +} |
| + |
| +// chunkEscaped splits s into pieces whose JSON-escaped length is at most |
| +// limit bytes, never inside a rune. |
| +func chunkEscaped(s string, limit int) []string { |
| + if s == "" { |
| + return nil |
| + } |
| + var out []string |
| + var b strings.Builder |
| + size := 0 |
| + for _, r := range s { |
| + enc, _ := json.Marshal(string(r)) |
| + n := len(enc) - 2 |
| + if size+n > limit && b.Len() > 0 { |
| + out = append(out, b.String()) |
| + b.Reset() |
| + size = 0 |
| + } |
| + b.WriteRune(r) |
| + size += n |
| + } |
| + if b.Len() > 0 { |
| + out = append(out, b.String()) |
| + } |
| + return out |
| +} |
| + |
| +// refAlphabet is Crockford's: no I, L, O or U, so a reference read aloud |
| +// or copied by hand comes back the same. |
| +const refAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" |
| + |
| +// newReportRef is a problem report's reference: eight characters, forty |
| +// random bits. |
| +func newReportRef() string { |
| + b := make([]byte, 8) |
| + if _, err := rand.Read(b); err != nil { |
| + panic(err) |
| + } |
| + for i := range b { |
| + b[i] = refAlphabet[int(b[i])%len(refAlphabet)] |
| + } |
| + return string(b) |
| +} |
diff --git a/internal/calls/ratelimit.go b/internal/calls/ratelimit.go| index 01aa9b4..6e2b238 100644 |
| --- a/internal/calls/ratelimit.go |
| +++ b/internal/calls/ratelimit.go |
| @@ -50,6 +50,12 @@ func newRateLimiter(rate, burst, maxKeys int) *rateLimiter { |
| return &rateLimiter{rate: float64(rate), burst: float64(burst), maxKeys: maxKeys, buckets: map[string]*tokenBucket{}} |
| } |
| +// newRateLimiterEvery is a limiter that adds one token per interval: for |
| +// the report endpoints, whose rates are slower than one a second. |
| +func newRateLimiterEvery(every time.Duration, burst, maxKeys int) *rateLimiter { |
| + return &rateLimiter{rate: 1 / every.Seconds(), burst: float64(burst), maxKeys: maxKeys, buckets: map[string]*tokenBucket{}} |
| +} |
| + |
| // allow spends one token for key, refilling by the time elapsed since |
| // its last visit. ok is false when the bucket is empty, in which case |
| // retryAfter is how long until one token is available. |
diff --git a/internal/calls/stream.go b/internal/calls/stream.go| index 645c5d0..18b76b3 100644 |
| --- a/internal/calls/stream.go |
| +++ b/internal/calls/stream.go |
| @@ -223,7 +223,7 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| // browser discards what is at or below the seq it already has, |
| // so a fresh candidate written ahead of the replay would make |
| // the replay look stale. |
| - first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()}) |
| + first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot(), Report: res.ReportToken}) |
| if res.Admitted { |
| first = append(first, hub.Message{Type: hub.TypeRoster, Roster: res.Roster}) |
| first = append(first, res.Replay...) |
| @@ -284,7 +284,7 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| sub, peer = res.Sub, res.Peer |
| - first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()}) |
| + first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot(), Report: res.ReportToken}) |
| } |
| // From here the participation exists and is this handler's to hold |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index e8ba0c4..de032fd 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -308,6 +308,41 @@ type browserRig struct { |
| cookieName string |
| cookieA string |
| cookieB string |
| + |
| + // reports is every quality and problem report line the app has |
| + // written (spec §15.26), as the deployed app writes them: JSON. |
| + reports reportLog |
| +} |
| + |
| +// reportLog is a goroutine-safe sink for the app's report logger. |
| +type reportLog struct { |
| + mu sync.Mutex |
| + buf bytes.Buffer |
| +} |
| + |
| +func (l *reportLog) Write(p []byte) (int, error) { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.buf.Write(p) |
| +} |
| + |
| +// raw is everything written so far. |
| +func (l *reportLog) raw() string { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.buf.String() |
| +} |
| + |
| +// lines are the report lines with msg, decoded. |
| +func (l *reportLog) lines(msg string) []map[string]any { |
| + var out []map[string]any |
| + for _, line := range strings.Split(l.raw(), "\n") { |
| + var m map[string]any |
| + if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == msg { |
| + out = append(out, m) |
| + } |
| + } |
| + return out |
| } |
| func newBrowserRig(t *testing.T) *browserRig { |
| @@ -352,6 +387,7 @@ func (rig *browserRig) serve(t *testing.T, ln net.Listener) { |
| AllowedAddresses: []string{memberA, memberB}, |
| Mailer: rig.mail, |
| Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + ReportLogger: slog.New(slog.NewJSONHandler(&rig.reports, nil)), |
| }) |
| if err != nil { |
| t.Fatalf("calls.NewForTest: %v", err) |
diff --git a/protocol/messages.go b/protocol/messages.go| index ea7f11c..627eced 100644 |
| --- a/protocol/messages.go |
| +++ b/protocol/messages.go |
| @@ -89,6 +89,10 @@ type Message struct { |
| Boot string `json:"boot,omitempty"` |
| Peer PeerID `json:"peer,omitempty"` |
| Waiting bool `json:"waiting,omitempty"` |
| + // Report is hello's report token (spec §15.26): the client's authority |
| + // to send quality and problem reports, for its participation and ten |
| + // minutes after. Sent back in the X-Calls-Report header, never logged. |
| + Report string `json:"report,omitempty"` |
| } |
| // Roster is the authoritative snapshot a browser reconciles against |
diff --git a/protocol/quality.go b/protocol/quality.go| new file mode 100644 |
| index 0000000..db21780 |
| --- /dev/null |
| +++ b/protocol/quality.go |
| @@ -0,0 +1,191 @@ |
| +package protocol |
| + |
| +// Quality and problem reports (spec §15.26, 2026-10-03): the wire shape a |
| +// client POSTs, client-neutral like the rest of this package. |
| +// |
| +// A quality report is a fixed schema of numbers and enums about how this |
| +// client's connections went over the last window: no names, no addresses, |
| +// no device names, no meeting, nothing typed by a person. Every number is |
| +// optional (absent means "this client could not measure it"), finite and |
| +// non-negative, and has a ceiling the server enforces; every string is an |
| +// enum or a version. Counts are what happened during the window (a delta), |
| +// not running totals. The server re-encodes what it validated and drops |
| +// anything it does not know, so a member a newer client adds is tolerated |
| +// and never logged. |
| + |
| +// QualityVersion is the schema's version, the report's "v". |
| +const QualityVersion = 1 |
| + |
| +// Bounds the server enforces, here so a client can clamp to the same ones. |
| +const ( |
| + MaxQualityPeers = 8 // remote peers in one report (the mesh holds three) |
| + MaxQualitySeconds = 600 // a window, in seconds |
| + MaxQualityMillis = 600_000 // any rtt, jitter or buffer delay, in ms |
| + MaxQualityCount = 1e9 // any count of packets, frames or samples |
| + MaxQualityKbps = 1e6 // any bitrate, in kbit/s |
| + MaxQualityPixels = 16384 // a frame's width or height |
| + MaxQualityFPS = 240 // frames per second |
| + MaxQualityBody = 16 << 10 |
| + MaxProblemNote = 1000 // characters of "What went wrong?" |
| + MaxProblemBody = 64 << 10 // the whole problem report, diagnostics included |
| +) |
| + |
| +// QualityReport is one window's summary. |
| +type QualityReport struct { |
| + V int `json:"v"` |
| + Final bool `json:"final,omitempty"` // the report sent at leave or end |
| + Seconds *float64 `json:"seconds"` // how long the window was |
| + Client ReportClient `json:"client"` |
| + Devices *Devices `json:"devices,omitempty"` |
| + Events *CallEvents `json:"events,omitempty"` |
| + Peers []PeerQuality `json:"peers,omitempty"` |
| +} |
| + |
| +// ReportClient says what is reporting. Enums, and versions as digits and |
| +// dots: never a user agent string, a device model or a machine name. |
| +type ReportClient struct { |
| + App string `json:"app"` // web, mac, iphone, ipad |
| + Version string `json:"version,omitempty"` // the app's version, or the server's build for web |
| + Browser string `json:"browser,omitempty"` // chrome, safari, firefox, edge, other (web only) |
| + BrowserVersion string `json:"browserVersion,omitempty"` // major[.minor] |
| + OS string `json:"os,omitempty"` // macos, ios, ipados, windows, linux, android, chromeos, other |
| + OSVersion string `json:"osVersion,omitempty"` |
| +} |
| + |
| +// Devices are the kinds of microphone and speaker in use, by transport: |
| +// never a device's name, which can be personal ("Paul's AirPods Pro"). |
| +type Devices struct { |
| + Input string `json:"input,omitempty"` // DeviceKinds |
| + Output string `json:"output,omitempty"` // DeviceKinds |
| +} |
| + |
| +// DeviceKinds are the values Devices may carry. |
| +var DeviceKinds = []string{"built-in", "bluetooth", "usb", "wired", "airplay", "virtual", "continuity", "other", "none", "unknown"} |
| + |
| +// CallEvents are the notices this client showed for the whole call during |
| +// the window (§15.23), and whether its browser blocked playing sound |
| +// (§15.20). |
| +type CallEvents struct { |
| + Reconnecting *float64 `json:"reconnecting,omitempty"` // times "Reconnecting…" was shown |
| + ReconnectingSeconds *float64 `json:"reconnectingSeconds,omitempty"` // for how long |
| + AudioBlocked bool `json:"audioBlocked,omitempty"` |
| +} |
| + |
| +// PeerQuality is one remote peer's connection, from this side. |
| +type PeerQuality struct { |
| + // Peer is the remote peer's id as this client knows it. The server |
| + // logs the peer's tag in its place and never the id. |
| + Peer PeerID `json:"peer"` |
| + Path *Path `json:"path,omitempty"` |
| + RTT *float64 `json:"rtt,omitempty"` // the selected candidate pair's, ms |
| + Out *Outbound `json:"out,omitempty"` |
| + In *Inbound `json:"in,omitempty"` |
| + Events *PeerEvents `json:"events,omitempty"` |
| +} |
| + |
| +// Path is the selected candidate pair: what kind of candidate each end |
| +// used, and the transport. Never an address or a port. |
| +type Path struct { |
| + Local string `json:"local,omitempty"` // CandidateKinds |
| + Remote string `json:"remote,omitempty"` // CandidateKinds |
| + Protocol string `json:"protocol,omitempty"` // udp, tcp, tls, unknown |
| +} |
| + |
| +// CandidateKinds are the values Path.Local and Path.Remote may carry. |
| +var CandidateKinds = []string{"host", "srflx", "prflx", "relay", "unknown"} |
| + |
| +// PathProtocols are the values Path.Protocol may carry. "tls" is a relay |
| +// reached over TURN/TLS. |
| +var PathProtocols = []string{"udp", "tcp", "tls", "unknown"} |
| + |
| +// Outbound is what this client sends to the peer. |
| +type Outbound struct { |
| + Audio *OutStream `json:"audio,omitempty"` |
| + Video *OutStream `json:"video,omitempty"` |
| + Screen *OutStream `json:"screen,omitempty"` |
| +} |
| + |
| +// OutStream is one outbound stream. Lost, RTT and Jitter are the |
| +// receiver's own account (remote-inbound-rtp). |
| +type OutStream struct { |
| + Kbps *float64 `json:"kbps,omitempty"` |
| + Packets *float64 `json:"packets,omitempty"` |
| + Lost *float64 `json:"lost,omitempty"` |
| + RTT *float64 `json:"rtt,omitempty"` |
| + Jitter *float64 `json:"jitter,omitempty"` |
| + Limit string `json:"limit,omitempty"` // QualityLimits |
| + Width *float64 `json:"width,omitempty"` |
| + Height *float64 `json:"height,omitempty"` |
| + FPS *float64 `json:"fps,omitempty"` |
| + // The screen-share recovery ladder's steps toward this peer during |
| + // the window (§15.22): keyframes sent, rebuilds, shares again, and |
| + // times it gave up. |
| + Keyframes *float64 `json:"keyframes,omitempty"` |
| + Rebuilds *float64 `json:"rebuilds,omitempty"` |
| + Reshares *float64 `json:"reshares,omitempty"` |
| + Failures *float64 `json:"failures,omitempty"` |
| +} |
| + |
| +// QualityLimits are qualityLimitationReason's values. |
| +var QualityLimits = []string{"none", "cpu", "bandwidth", "other"} |
| + |
| +// Inbound is what arrives from the peer. |
| +type Inbound struct { |
| + Audio *InAudio `json:"audio,omitempty"` |
| + Video *InVideo `json:"video,omitempty"` |
| + Screen *InVideo `json:"screen,omitempty"` |
| +} |
| + |
| +// InAudio is the peer's audio as it arrives here. |
| +type InAudio struct { |
| + Kbps *float64 `json:"kbps,omitempty"` |
| + Packets *float64 `json:"packets,omitempty"` |
| + Lost *float64 `json:"lost,omitempty"` |
| + Jitter *float64 `json:"jitter,omitempty"` // ms |
| + Samples *float64 `json:"samples,omitempty"` // totalSamplesReceived |
| + Concealed *float64 `json:"concealed,omitempty"` // concealedSamples |
| + Inserted *float64 `json:"inserted,omitempty"` // insertedSamplesForDeceleration |
| + Removed *float64 `json:"removed,omitempty"` // removedSamplesForAcceleration |
| + JitterBufferMs *float64 `json:"jitterBufferMs,omitempty"` // mean jitterBufferDelay per emitted sample over the window |
| +} |
| + |
| +// InVideo is the peer's camera or screen as it arrives here. |
| +type InVideo struct { |
| + Kbps *float64 `json:"kbps,omitempty"` |
| + Packets *float64 `json:"packets,omitempty"` |
| + Lost *float64 `json:"lost,omitempty"` |
| + Jitter *float64 `json:"jitter,omitempty"` |
| + Freezes *float64 `json:"freezes,omitempty"` // freezeCount |
| + FreezeSeconds *float64 `json:"freezeSeconds,omitempty"` // totalFreezesDuration |
| + Decoded *float64 `json:"decoded,omitempty"` |
| + Dropped *float64 `json:"dropped,omitempty"` |
| + Width *float64 `json:"width,omitempty"` |
| + Height *float64 `json:"height,omitempty"` |
| + FPS *float64 `json:"fps,omitempty"` |
| + Reattaches *float64 `json:"reattaches,omitempty"` // a stalled screen handed to its view again (§15.22) |
| +} |
| + |
| +// PeerEvents are the notices about this peer during the window. |
| +type PeerEvents struct { |
| + Reconnecting *float64 `json:"reconnecting,omitempty"` // "Reconnecting to …" shown |
| + ReconnectingSeconds *float64 `json:"reconnectingSeconds,omitempty"` // for how long |
| + CantHearSeconds *float64 `json:"cantHearSeconds,omitempty"` // "… can't hear you yet" (§15.24) |
| + NoSound bool `json:"noSound,omitempty"` // this client listed them in noSoundFrom (§15.20) |
| + NoSoundFromUs bool `json:"noSoundFromUs,omitempty"` // they listed this client |
| + AudioBlocked bool `json:"audioBlocked,omitempty"` // they said their browser blocked sound |
| + ScreenStalled bool `json:"screenStalled,omitempty"` // this client reported their screen stalled (§15.21) |
| +} |
| + |
| +// ProblemReport is "Report a problem": what the person typed, and the |
| +// text "Copy call diagnostics" would copy, with the people in the call |
| +// named by short peer ids rather than their names. |
| +type ProblemReport struct { |
| + Note string `json:"note"` |
| + Diagnostics string `json:"diagnostics"` |
| + Client ReportClient `json:"client"` |
| +} |
| + |
| +// ProblemReceipt is the server's answer: the reference to quote. |
| +type ProblemReceipt struct { |
| + Ref string `json:"ref"` |
| +} |