// Command quality-summary reads Calls' quality and problem report lines // (spec §15.26) and prints what happened in each call: who was in it, by // peer tag and platform; for every direction between two people, the // worst loss, the worst round trip, the freezes and how much of the time // the media went through the relay; and the notices people saw, in order. // // It reads `carlos logs` output on standard input, or, with nothing piped // in, runs `carlos logs` itself: // // quality-summary --since 3h # every call in the last three hours // quality-summary --since 24h --at 15:40 # the call running at 15:40 (Europe/Dublin) // quality-summary --call 3k2jq7m4xw5zt6ab # one call, by its tag // quality-summary --problem K7Q2M9PD --since 24h # one problem report, diagnostics and all // carlos logs --app calls --account oficina --since 2h -n 5000 | quality-summary // // Nothing here knows a meeting, a person or a name: the lines carry none. package main import ( "bufio" "encoding/json" "errors" "flag" "fmt" "io" "os" "os/exec" "sort" "strings" "time" "amadan.net/oficina/calls/protocol" ) func main() { if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil { fmt.Fprintln(os.Stderr, "quality-summary:", err) os.Exit(1) } } type options struct { since, app, account, at, call, problem, zone string } func run(args []string, stdin *os.File, out io.Writer) error { fs := flag.NewFlagSet("quality-summary", flag.ContinueOnError) var o options fs.StringVar(&o.since, "since", "2h", "how far back carlos logs reads (a duration, or RFC3339)") fs.StringVar(&o.app, "app", "calls", "the carlos app") fs.StringVar(&o.account, "account", "oficina", "the carlos account") fs.StringVar(&o.at, "at", "", "only the call(s) running at this time, HH:MM in --zone, today (or YYYY-MM-DD HH:MM)") fs.StringVar(&o.call, "call", "", "only the call with this tag") fs.StringVar(&o.problem, "problem", "", "print one problem report, by its reference, with its diagnostics") fs.StringVar(&o.zone, "zone", "Europe/Dublin", "the zone times are shown and --at is read in") if err := fs.Parse(args); err != nil { return err } loc, err := time.LoadLocation(o.zone) if err != nil { return err } var in io.Reader = stdin if st, err := stdin.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 { cmd := exec.Command("carlos", "logs", "--app", o.app, "--account", o.account, "--since", o.since, "-n", "5000", "--grep", "^calls: (quality|problem report)") cmd.Stderr = os.Stderr b, err := cmd.Output() if err != nil { return fmt.Errorf("carlos logs: %w", err) } in = strings.NewReader(string(b)) } lines, err := parse(in) if err != nil { return err } if o.problem != "" { return printProblem(out, lines, strings.ToUpper(o.problem), loc) } calls := gather(lines) var at time.Time if o.at != "" { if at, err = parseAt(o.at, loc, time.Now()); err != nil { return err } } shown := 0 for _, c := range calls { if o.call != "" && c.tag != o.call { continue } // A report covers the thirty seconds before it. if !at.IsZero() && (at.Before(c.first.Add(-30*time.Second)) || at.After(c.last.Add(30*time.Second))) { continue } c.print(out, loc) shown++ } if shown == 0 { fmt.Fprintln(out, "No calls in these lines match. Try a longer --since; carlos keeps an app's logs for seven days.") } return nil } // parseAt reads "15:40" as today in loc, or "2026-10-03 15:40". func parseAt(s string, loc *time.Location, now time.Time) (time.Time, error) { if t, err := time.ParseInLocation("2006-01-02 15:04", s, loc); err == nil { return t, nil } t, err := time.ParseInLocation("15:04", s, loc) if err != nil { return time.Time{}, errors.New("--at wants HH:MM or YYYY-MM-DD HH:MM") } n := now.In(loc) return time.Date(n.Year(), n.Month(), n.Day(), t.Hour(), t.Minute(), 0, 0, loc), nil } // line is one report line, as the app wrote it. type line struct { Time time.Time `json:"time"` Msg string `json:"msg"` Call string `json:"call"` Peer string `json:"peer"` App string `json:"app"` Version string `json:"version"` Browser string `json:"browser"` BrowVer string `json:"browserVersion"` OS string `json:"os"` OSVer string `json:"osVersion"` Final bool `json:"final"` Ref string `json:"ref"` Note string `json:"note"` Live bool `json:"live"` Part int `json:"part"` Of int `json:"of"` Text string `json:"text"` Report struct { Seconds *float64 `json:"seconds"` Devices *protocol.Devices `json:"devices"` Events *protocol.CallEvents `json:"events"` Peers []protocol.PeerQuality `json:"peers"` } `json:"report"` } // parse takes every line that carries a report, whatever carlos put in // front of it: the JSON object starts at the first "{". func parse(r io.Reader) ([]line, error) { var out []line sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64<<10), 1<<20) for sc.Scan() { s := sc.Text() i := strings.Index(s, `{"time"`) if i < 0 { continue } var l line if json.Unmarshal([]byte(s[i:]), &l) != nil || !strings.HasPrefix(l.Msg, "calls: ") { continue } out = append(out, l) } return out, sc.Err() } type person struct { tag, app, version, platform string devices string reports int } // direction is what one person received from another, over every window. type direction struct { windows, pathWindows, relayWindows int paths map[string]int worstLoss float64 worstLossOf string worstRTT float64 freezes, freezeSeconds float64 samples, concealed float64 cantHear float64 reconnecting float64 reconnectingSeconds float64 noSound, stalled, blocked int screenSteps float64 } type event struct { at time.Time text string } type call struct { tag string first, last time.Time people map[string]*person order []string dirs map[[2]string]*direction events []event problems []line } func gather(lines []line) []*call { byTag := map[string]*call{} var calls []*call for _, l := range lines { if l.Call == "" || l.Msg == "calls: problem report diagnostics" { continue } c := byTag[l.Call] if c == nil { c = &call{tag: l.Call, first: l.Time, last: l.Time, people: map[string]*person{}, dirs: map[[2]string]*direction{}} byTag[l.Call] = c calls = append(calls, c) } if l.Time.Before(c.first) { c.first = l.Time } if l.Time.After(c.last) { c.last = l.Time } p := c.person(l) switch l.Msg { case "calls: problem report": c.problems = append(c.problems, l) case "calls: quality": c.addQuality(p, l) } } sort.Slice(calls, func(i, j int) bool { return calls[i].first.Before(calls[j].first) }) return calls } func (c *call) person(l line) *person { p := c.people[l.Peer] if p == nil { p = &person{tag: l.Peer} c.people[l.Peer] = p c.order = append(c.order, l.Peer) } // The quality lines say what the client is; a problem report's head // says it too, and fills in only for someone who sent no other line. if l.App != "" && (l.Msg == "calls: quality" || p.app == "") { p.app, p.version = l.App, l.Version switch { case l.Browser != "": p.platform = strings.TrimSpace(fmt.Sprintf("%s %s on %s %s", l.Browser, l.BrowVer, l.OS, l.OSVer)) case l.OS != "": p.platform = strings.TrimSpace(l.OS + " " + l.OSVer) } } return p } func val(p *float64) float64 { if p == nil { return 0 } return *p } func lossPct(packets, lost *float64) float64 { n, l := val(packets), val(lost) if n+l == 0 { return 0 } return 100 * l / (n + l) } func (c *call) addQuality(p *person, l line) { p.reports++ r := l.Report if r.Devices != nil { p.devices = fmt.Sprintf("input %s, output %s", orDash(r.Devices.Input), orDash(r.Devices.Output)) } if e := r.Events; e != nil { if val(e.Reconnecting) > 0 { c.events = append(c.events, event{l.Time, fmt.Sprintf("%s saw \"Reconnecting…\" for the whole call (%gx, %.1f s)", short(p.tag), val(e.Reconnecting), val(e.ReconnectingSeconds))}) } if e.AudioBlocked { c.events = append(c.events, event{l.Time, short(p.tag) + "'s browser blocked sound"}) } } for _, q := range r.Peers { key := [2]string{p.tag, string(q.Peer)} d := c.dirs[key] if d == nil { d = &direction{paths: map[string]int{}} c.dirs[key] = d } d.windows++ if q.Path != nil { d.pathWindows++ d.paths[fmt.Sprintf("%s→%s %s", orDash(q.Path.Local), orDash(q.Path.Remote), orDash(q.Path.Protocol))]++ if q.Path.Local == "relay" || q.Path.Remote == "relay" { d.relayWindows++ } } d.worstRTT = max(d.worstRTT, val(q.RTT)) if q.Out != nil { for _, o := range []*protocol.OutStream{q.Out.Audio, q.Out.Video, q.Out.Screen} { if o != nil { d.worstRTT = max(d.worstRTT, val(o.RTT)) } } if s := q.Out.Screen; s != nil { d.screenSteps += val(s.Rebuilds) + val(s.Reshares) + val(s.Failures) } } if in := q.In; in != nil { if a := in.Audio; a != nil { if x := lossPct(a.Packets, a.Lost); x > d.worstLoss { d.worstLoss, d.worstLossOf = x, "audio" } d.samples += val(a.Samples) d.concealed += val(a.Concealed) } for kind, v := range map[string]*protocol.InVideo{"video": in.Video, "screen": in.Screen} { if v == nil { continue } if x := lossPct(v.Packets, v.Lost); x > d.worstLoss { d.worstLoss, d.worstLossOf = x, kind } d.freezes += val(v.Freezes) d.freezeSeconds += val(v.FreezeSeconds) } } if e := q.Events; e != nil { d.cantHear += val(e.CantHearSeconds) d.reconnecting += val(e.Reconnecting) d.reconnectingSeconds += val(e.ReconnectingSeconds) var said []string if val(e.Reconnecting) > 0 { said = append(said, fmt.Sprintf("\"Reconnecting to %s…\" (%.1f s)", short(string(q.Peer)), val(e.ReconnectingSeconds))) } if val(e.CantHearSeconds) > 0 { said = append(said, fmt.Sprintf("\"%s can't hear you yet\" (%.1f s)", short(string(q.Peer)), val(e.CantHearSeconds))) } if e.NoSound { d.noSound++ said = append(said, "no sound from "+short(string(q.Peer))) } if e.NoSoundFromUs { said = append(said, short(string(q.Peer))+" said they get no sound from them") } if e.ScreenStalled { d.stalled++ said = append(said, short(string(q.Peer))+"'s screen stalled") } if e.AudioBlocked { d.blocked++ said = append(said, short(string(q.Peer))+"'s browser blocks sound") } if len(said) > 0 { c.events = append(c.events, event{l.Time, short(p.tag) + ": " + strings.Join(said, "; ")}) } } } if l.Final { c.events = append(c.events, event{l.Time, short(p.tag) + " left (final report)"}) } } func orDash(s string) string { if s == "" { return "-" } return s } // short is a tag as the summary prints it: six characters are plenty to // tell four people apart, and the full tag is in --call's output header. func short(tag string) string { if len(tag) > 6 { return tag[:6] } return tag } func (c *call) print(out io.Writer, loc *time.Location) { f, l := c.first.In(loc), c.last.In(loc) fmt.Fprintf(out, "Call %s %s – %s %s (%s), %d people, %d problem report(s)\n", c.tag, f.Format("2006-01-02 15:04:05"), l.Format("15:04:05"), loc, l.Sub(f).Round(time.Second), len(c.people), len(c.problems)) fmt.Fprintln(out, " People") for _, tag := range c.order { p := c.people[tag] fmt.Fprintf(out, " %s %s %s %s %d reports", short(tag), orDash(p.app), p.version, p.platform, p.reports) if p.devices != "" { fmt.Fprintf(out, " (%s)", p.devices) } fmt.Fprintln(out) } if len(c.dirs) > 0 { fmt.Fprintln(out, " Connections (what the first heard from the second)") keys := make([][2]string, 0, len(c.dirs)) for k := range c.dirs { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return keys[i][0]+keys[i][1] < keys[j][0]+keys[j][1] }) for _, k := range keys { d := c.dirs[k] path, n := "", 0 for p, m := range d.paths { if m > n || (m == n && p < path) { path, n = p, m } } relay := "-" if d.pathWindows > 0 { relay = fmt.Sprintf("%.0f%%", 100*float64(d.relayWindows)/float64(d.pathWindows)) } fmt.Fprintf(out, " %s ← %s worst loss %.1f%%%s, worst RTT %.0f ms, freezes %g (%.1f s)", short(k[0]), short(k[1]), d.worstLoss, ofWhat(d.worstLossOf), d.worstRTT, d.freezes, d.freezeSeconds) if d.samples > 0 { fmt.Fprintf(out, ", audio concealed %.2f%%", 100*d.concealed/d.samples) } fmt.Fprintf(out, ", path %s, relay %s", orDash(path), relay) if d.screenSteps > 0 { fmt.Fprintf(out, ", screen recovery steps %g", d.screenSteps) } fmt.Fprintln(out) } } if len(c.events) > 0 { fmt.Fprintln(out, " Events") sort.SliceStable(c.events, func(i, j int) bool { return c.events[i].at.Before(c.events[j].at) }) for _, e := range c.events { fmt.Fprintf(out, " %s %s\n", e.at.In(loc).Format("15:04:05"), e.text) } } for _, p := range c.problems { note := p.Note if note == "" { note = "(no note)" } fmt.Fprintf(out, " Problem %s at %s from %s (%s): %q\n quality-summary --problem %s --since \n", p.Ref, p.Time.In(loc).Format("15:04:05"), short(p.Peer), p.App, note, p.Ref) } fmt.Fprintln(out) } func ofWhat(kind string) string { if kind == "" { return "" } return " (" + kind + ")" } func printProblem(out io.Writer, lines []line, ref string, loc *time.Location) error { var head *line parts := map[int]string{} of := 0 for i := range lines { l := &lines[i] if l.Ref != ref { continue } switch l.Msg { case "calls: problem report": head = l case "calls: problem report diagnostics": parts[l.Part] = l.Text of = l.Of } } if head == nil { return fmt.Errorf("no problem report %s in these lines (try a longer --since)", ref) } fmt.Fprintf(out, "Problem report %s, %s\n", ref, head.Time.In(loc).Format("2006-01-02 15:04:05 MST")) fmt.Fprintf(out, "call %s, peer %s, %s %s %s %s %s %s, live %v\n", head.Call, head.Peer, head.App, head.Version, head.Browser, head.BrowVer, head.OS, head.OSVer, head.Live) fmt.Fprintf(out, "note: %s\n\n", orDash(head.Note)) for i := 1; i <= of; i++ { t, ok := parts[i] if !ok { fmt.Fprintf(out, "\n[part %d of %d is missing]\n", i, of) continue } fmt.Fprint(out, t) } fmt.Fprintln(out) return nil }