oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1// Command quality-summary reads Calls' quality and problem report lines
2// (spec §15.26) and prints what happened in each call: who was in it, by
3// peer tag and platform; for every direction between two people, the
4// worst loss, the worst round trip, the freezes and how much of the time
5// the media went through the relay; and the notices people saw, in order.
6//
7// It reads `carlos logs` output on standard input, or, with nothing piped
8// in, runs `carlos logs` itself:
9//
10// quality-summary --since 3h # every call in the last three hours
11// quality-summary --since 24h --at 15:40 # the call running at 15:40 (Europe/Dublin)
12// quality-summary --call 3k2jq7m4xw5zt6ab # one call, by its tag
13// quality-summary --problem K7Q2M9PD --since 24h # one problem report, diagnostics and all
14// carlos logs --app calls --account oficina --since 2h -n 5000 | quality-summary
15//
16// Nothing here knows a meeting, a person or a name: the lines carry none.
17package main
18
19import (
20 "bufio"
21 "encoding/json"
22 "errors"
23 "flag"
24 "fmt"
25 "io"
26 "os"
27 "os/exec"
28 "sort"
29 "strings"
30 "time"
31
32 "amadan.net/oficina/calls/protocol"
33)
34
35func main() {
36 if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
37 fmt.Fprintln(os.Stderr, "quality-summary:", err)
38 os.Exit(1)
39 }
40}
41
42type options struct {
43 since, app, account, at, call, problem, zone string
44}
45
46func run(args []string, stdin *os.File, out io.Writer) error {
47 fs := flag.NewFlagSet("quality-summary", flag.ContinueOnError)
48 var o options
49 fs.StringVar(&o.since, "since", "2h", "how far back carlos logs reads (a duration, or RFC3339)")
50 fs.StringVar(&o.app, "app", "calls", "the carlos app")
51 fs.StringVar(&o.account, "account", "oficina", "the carlos account")
52 fs.StringVar(&o.at, "at", "", "only the call(s) running at this time, HH:MM in --zone, today (or YYYY-MM-DD HH:MM)")
53 fs.StringVar(&o.call, "call", "", "only the call with this tag")
54 fs.StringVar(&o.problem, "problem", "", "print one problem report, by its reference, with its diagnostics")
55 fs.StringVar(&o.zone, "zone", "Europe/Dublin", "the zone times are shown and --at is read in")
56 if err := fs.Parse(args); err != nil {
57 return err
58 }
59 loc, err := time.LoadLocation(o.zone)
60 if err != nil {
61 return err
62 }
63 var in io.Reader = stdin
64 if st, err := stdin.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 {
65 cmd := exec.Command("carlos", "logs", "--app", o.app, "--account", o.account, "--since", o.since,
66 "-n", "5000", "--grep", "^calls: (quality|problem report)")
67 cmd.Stderr = os.Stderr
68 b, err := cmd.Output()
69 if err != nil {
70 return fmt.Errorf("carlos logs: %w", err)
71 }
72 in = strings.NewReader(string(b))
73 }
74 lines, err := parse(in)
75 if err != nil {
76 return err
77 }
78 if o.problem != "" {
79 return printProblem(out, lines, strings.ToUpper(o.problem), loc)
80 }
81 calls := gather(lines)
82 var at time.Time
83 if o.at != "" {
84 if at, err = parseAt(o.at, loc, time.Now()); err != nil {
85 return err
86 }
87 }
88 shown := 0
89 for _, c := range calls {
90 if o.call != "" && c.tag != o.call {
91 continue
92 }
93 // A report covers the thirty seconds before it.
94 if !at.IsZero() && (at.Before(c.first.Add(-30*time.Second)) || at.After(c.last.Add(30*time.Second))) {
95 continue
96 }
97 c.print(out, loc)
98 shown++
99 }
100 if shown == 0 {
101 fmt.Fprintln(out, "No calls in these lines match. Try a longer --since; carlos keeps an app's logs for seven days.")
102 }
103 return nil
104}
105
106// parseAt reads "15:40" as today in loc, or "2026-10-03 15:40".
107func parseAt(s string, loc *time.Location, now time.Time) (time.Time, error) {
108 if t, err := time.ParseInLocation("2006-01-02 15:04", s, loc); err == nil {
109 return t, nil
110 }
111 t, err := time.ParseInLocation("15:04", s, loc)
112 if err != nil {
113 return time.Time{}, errors.New("--at wants HH:MM or YYYY-MM-DD HH:MM")
114 }
115 n := now.In(loc)
116 return time.Date(n.Year(), n.Month(), n.Day(), t.Hour(), t.Minute(), 0, 0, loc), nil
117}
118
119// line is one report line, as the app wrote it.
120type line struct {
121 Time time.Time `json:"time"`
122 Msg string `json:"msg"`
123 Call string `json:"call"`
124 Peer string `json:"peer"`
125 App string `json:"app"`
126 Version string `json:"version"`
127 Browser string `json:"browser"`
128 BrowVer string `json:"browserVersion"`
129 OS string `json:"os"`
130 OSVer string `json:"osVersion"`
131 Final bool `json:"final"`
132 Ref string `json:"ref"`
133 Note string `json:"note"`
134 Live bool `json:"live"`
135 Part int `json:"part"`
136 Of int `json:"of"`
137 Text string `json:"text"`
138 Report struct {
139 Seconds *float64 `json:"seconds"`
140 Devices *protocol.Devices `json:"devices"`
141 Events *protocol.CallEvents `json:"events"`
142 Peers []protocol.PeerQuality `json:"peers"`
143 } `json:"report"`
144}
145
146// parse takes every line that carries a report, whatever carlos put in
147// front of it: the JSON object starts at the first "{".
148func parse(r io.Reader) ([]line, error) {
149 var out []line
150 sc := bufio.NewScanner(r)
151 sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
152 for sc.Scan() {
153 s := sc.Text()
154 i := strings.Index(s, `{"time"`)
155 if i < 0 {
156 continue
157 }
158 var l line
159 if json.Unmarshal([]byte(s[i:]), &l) != nil || !strings.HasPrefix(l.Msg, "calls: ") {
160 continue
161 }
162 out = append(out, l)
163 }
164 return out, sc.Err()
165}
166
167type person struct {
168 tag, app, version, platform string
169 devices string
170 reports int
171}
172
173// direction is what one person received from another, over every window.
174type direction struct {
175 windows, pathWindows, relayWindows int
176 paths map[string]int
177 worstLoss float64
178 worstLossOf string
179 worstRTT float64
180 freezes, freezeSeconds float64
181 samples, concealed float64
182 cantHear float64
183 reconnecting float64
184 reconnectingSeconds float64
185 noSound, stalled, blocked int
186 screenSteps float64
187}
188
189type event struct {
190 at time.Time
191 text string
192}
193
194type call struct {
195 tag string
196 first, last time.Time
197 people map[string]*person
198 order []string
199 dirs map[[2]string]*direction
200 events []event
201 problems []line
202}
203
204func gather(lines []line) []*call {
205 byTag := map[string]*call{}
206 var calls []*call
207 for _, l := range lines {
208 if l.Call == "" || l.Msg == "calls: problem report diagnostics" {
209 continue
210 }
211 c := byTag[l.Call]
212 if c == nil {
213 c = &call{tag: l.Call, first: l.Time, last: l.Time, people: map[string]*person{}, dirs: map[[2]string]*direction{}}
214 byTag[l.Call] = c
215 calls = append(calls, c)
216 }
217 if l.Time.Before(c.first) {
218 c.first = l.Time
219 }
220 if l.Time.After(c.last) {
221 c.last = l.Time
222 }
223 p := c.person(l)
224 switch l.Msg {
225 case "calls: problem report":
226 c.problems = append(c.problems, l)
227 case "calls: quality":
228 c.addQuality(p, l)
229 }
230 }
231 sort.Slice(calls, func(i, j int) bool { return calls[i].first.Before(calls[j].first) })
232 return calls
233}
234
235func (c *call) person(l line) *person {
236 p := c.people[l.Peer]
237 if p == nil {
238 p = &person{tag: l.Peer}
239 c.people[l.Peer] = p
240 c.order = append(c.order, l.Peer)
241 }
242 // The quality lines say what the client is; a problem report's head
243 // says it too, and fills in only for someone who sent no other line.
244 if l.App != "" && (l.Msg == "calls: quality" || p.app == "") {
245 p.app, p.version = l.App, l.Version
246 switch {
247 case l.Browser != "":
248 p.platform = strings.TrimSpace(fmt.Sprintf("%s %s on %s %s", l.Browser, l.BrowVer, l.OS, l.OSVer))
249 case l.OS != "":
250 p.platform = strings.TrimSpace(l.OS + " " + l.OSVer)
251 }
252 }
253 return p
254}
255
256func val(p *float64) float64 {
257 if p == nil {
258 return 0
259 }
260 return *p
261}
262
263func lossPct(packets, lost *float64) float64 {
264 n, l := val(packets), val(lost)
265 if n+l == 0 {
266 return 0
267 }
268 return 100 * l / (n + l)
269}
270
271func (c *call) addQuality(p *person, l line) {
272 p.reports++
273 r := l.Report
274 if r.Devices != nil {
275 p.devices = fmt.Sprintf("input %s, output %s", orDash(r.Devices.Input), orDash(r.Devices.Output))
276 }
277 if e := r.Events; e != nil {
278 if val(e.Reconnecting) > 0 {
279 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))})
280 }
281 if e.AudioBlocked {
282 c.events = append(c.events, event{l.Time, short(p.tag) + "'s browser blocked sound"})
283 }
284 }
285 for _, q := range r.Peers {
286 key := [2]string{p.tag, string(q.Peer)}
287 d := c.dirs[key]
288 if d == nil {
289 d = &direction{paths: map[string]int{}}
290 c.dirs[key] = d
291 }
292 d.windows++
293 if q.Path != nil {
294 d.pathWindows++
295 d.paths[fmt.Sprintf("%s→%s %s", orDash(q.Path.Local), orDash(q.Path.Remote), orDash(q.Path.Protocol))]++
296 if q.Path.Local == "relay" || q.Path.Remote == "relay" {
297 d.relayWindows++
298 }
299 }
300 d.worstRTT = max(d.worstRTT, val(q.RTT))
301 if q.Out != nil {
302 for _, o := range []*protocol.OutStream{q.Out.Audio, q.Out.Video, q.Out.Screen} {
303 if o != nil {
304 d.worstRTT = max(d.worstRTT, val(o.RTT))
305 }
306 }
307 if s := q.Out.Screen; s != nil {
308 d.screenSteps += val(s.Rebuilds) + val(s.Reshares) + val(s.Failures)
309 }
310 }
311 if in := q.In; in != nil {
312 if a := in.Audio; a != nil {
313 if x := lossPct(a.Packets, a.Lost); x > d.worstLoss {
314 d.worstLoss, d.worstLossOf = x, "audio"
315 }
316 d.samples += val(a.Samples)
317 d.concealed += val(a.Concealed)
318 }
319 for kind, v := range map[string]*protocol.InVideo{"video": in.Video, "screen": in.Screen} {
320 if v == nil {
321 continue
322 }
323 if x := lossPct(v.Packets, v.Lost); x > d.worstLoss {
324 d.worstLoss, d.worstLossOf = x, kind
325 }
326 d.freezes += val(v.Freezes)
327 d.freezeSeconds += val(v.FreezeSeconds)
328 }
329 }
330 if e := q.Events; e != nil {
331 d.cantHear += val(e.CantHearSeconds)
332 d.reconnecting += val(e.Reconnecting)
333 d.reconnectingSeconds += val(e.ReconnectingSeconds)
334 var said []string
335 if val(e.Reconnecting) > 0 {
336 said = append(said, fmt.Sprintf("\"Reconnecting to %s…\" (%.1f s)", short(string(q.Peer)), val(e.ReconnectingSeconds)))
337 }
338 if val(e.CantHearSeconds) > 0 {
339 said = append(said, fmt.Sprintf("\"%s can't hear you yet\" (%.1f s)", short(string(q.Peer)), val(e.CantHearSeconds)))
340 }
341 if e.NoSound {
342 d.noSound++
343 said = append(said, "no sound from "+short(string(q.Peer)))
344 }
345 if e.NoSoundFromUs {
346 said = append(said, short(string(q.Peer))+" said they get no sound from them")
347 }
348 if e.ScreenStalled {
349 d.stalled++
350 said = append(said, short(string(q.Peer))+"'s screen stalled")
351 }
352 if e.AudioBlocked {
353 d.blocked++
354 said = append(said, short(string(q.Peer))+"'s browser blocks sound")
355 }
356 if len(said) > 0 {
357 c.events = append(c.events, event{l.Time, short(p.tag) + ": " + strings.Join(said, "; ")})
358 }
359 }
360 }
361 if l.Final {
362 c.events = append(c.events, event{l.Time, short(p.tag) + " left (final report)"})
363 }
364}
365
366func orDash(s string) string {
367 if s == "" {
368 return "-"
369 }
370 return s
371}
372
373// short is a tag as the summary prints it: six characters are plenty to
374// tell four people apart, and the full tag is in --call's output header.
375func short(tag string) string {
376 if len(tag) > 6 {
377 return tag[:6]
378 }
379 return tag
380}
381
382func (c *call) print(out io.Writer, loc *time.Location) {
383 f, l := c.first.In(loc), c.last.In(loc)
384 fmt.Fprintf(out, "Call %s %s – %s %s (%s), %d people, %d problem report(s)\n",
385 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))
386 fmt.Fprintln(out, " People")
387 for _, tag := range c.order {
388 p := c.people[tag]
389 fmt.Fprintf(out, " %s %s %s %s %d reports", short(tag), orDash(p.app), p.version, p.platform, p.reports)
390 if p.devices != "" {
391 fmt.Fprintf(out, " (%s)", p.devices)
392 }
393 fmt.Fprintln(out)
394 }
395 if len(c.dirs) > 0 {
396 fmt.Fprintln(out, " Connections (what the first heard from the second)")
397 keys := make([][2]string, 0, len(c.dirs))
398 for k := range c.dirs {
399 keys = append(keys, k)
400 }
401 sort.Slice(keys, func(i, j int) bool { return keys[i][0]+keys[i][1] < keys[j][0]+keys[j][1] })
402 for _, k := range keys {
403 d := c.dirs[k]
404 path, n := "", 0
405 for p, m := range d.paths {
406 if m > n || (m == n && p < path) {
407 path, n = p, m
408 }
409 }
410 relay := "-"
411 if d.pathWindows > 0 {
412 relay = fmt.Sprintf("%.0f%%", 100*float64(d.relayWindows)/float64(d.pathWindows))
413 }
414 fmt.Fprintf(out, " %s ← %s worst loss %.1f%%%s, worst RTT %.0f ms, freezes %g (%.1f s)",
415 short(k[0]), short(k[1]), d.worstLoss, ofWhat(d.worstLossOf), d.worstRTT, d.freezes, d.freezeSeconds)
416 if d.samples > 0 {
417 fmt.Fprintf(out, ", audio concealed %.2f%%", 100*d.concealed/d.samples)
418 }
419 fmt.Fprintf(out, ", path %s, relay %s", orDash(path), relay)
420 if d.screenSteps > 0 {
421 fmt.Fprintf(out, ", screen recovery steps %g", d.screenSteps)
422 }
423 fmt.Fprintln(out)
424 }
425 }
426 if len(c.events) > 0 {
427 fmt.Fprintln(out, " Events")
428 sort.SliceStable(c.events, func(i, j int) bool { return c.events[i].at.Before(c.events[j].at) })
429 for _, e := range c.events {
430 fmt.Fprintf(out, " %s %s\n", e.at.In(loc).Format("15:04:05"), e.text)
431 }
432 }
433 for _, p := range c.problems {
434 note := p.Note
435 if note == "" {
436 note = "(no note)"
437 }
438 fmt.Fprintf(out, " Problem %s at %s from %s (%s): %q\n quality-summary --problem %s --since <far enough back>\n",
439 p.Ref, p.Time.In(loc).Format("15:04:05"), short(p.Peer), p.App, note, p.Ref)
440 }
441 fmt.Fprintln(out)
442}
443
444func ofWhat(kind string) string {
445 if kind == "" {
446 return ""
447 }
448 return " (" + kind + ")"
449}
450
451func printProblem(out io.Writer, lines []line, ref string, loc *time.Location) error {
452 var head *line
453 parts := map[int]string{}
454 of := 0
455 for i := range lines {
456 l := &lines[i]
457 if l.Ref != ref {
458 continue
459 }
460 switch l.Msg {
461 case "calls: problem report":
462 head = l
463 case "calls: problem report diagnostics":
464 parts[l.Part] = l.Text
465 of = l.Of
466 }
467 }
468 if head == nil {
469 return fmt.Errorf("no problem report %s in these lines (try a longer --since)", ref)
470 }
471 fmt.Fprintf(out, "Problem report %s, %s\n", ref, head.Time.In(loc).Format("2006-01-02 15:04:05 MST"))
472 fmt.Fprintf(out, "call %s, peer %s, %s %s %s %s %s %s, live %v\n", head.Call, head.Peer, head.App, head.Version,
473 head.Browser, head.BrowVer, head.OS, head.OSVer, head.Live)
474 fmt.Fprintf(out, "note: %s\n\n", orDash(head.Note))
475 for i := 1; i <= of; i++ {
476 t, ok := parts[i]
477 if !ok {
478 fmt.Fprintf(out, "\n[part %d of %d is missing]\n", i, of)
479 continue
480 }
481 fmt.Fprint(out, t)
482 }
483 fmt.Fprintln(out)
484 return nil
485}
486