client/local,cmd/tailscale/cli,ipn/localapi: expose eventbus graph (#16597)

Make it possible to dump the eventbus graph as JSON or DOT to both debug
and document what is communicated via the bus.

Updates #15160

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
This commit is contained in:
Claus Lensbøl 2025-07-18 10:55:17 -04:00 committed by GitHub
parent 93511be044
commit d334d9ba07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 125 additions and 0 deletions

View File

@ -432,6 +432,12 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
return res.Body, nil
}
// EventBusGraph returns a graph of active publishers and subscribers in the eventbus
// as a [eventbus.DebugTopics]
func (lc *Client) EventBusGraph(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-graph")
}
// StreamBusEvents returns an iterator of Tailscale bus events as they arrive.
// Each pair is a valid event and a nil error, or a zero event a non-nil error.
// In case of error, the iterator ends after the pair reporting the error.

View File

@ -6,6 +6,7 @@ package cli
import (
"bufio"
"bytes"
"cmp"
"context"
"encoding/binary"
"encoding/json"
@ -108,6 +109,17 @@ func debugCmd() *ffcli.Command {
Exec: runDaemonBusEvents,
ShortHelp: "Watch events on the tailscaled bus",
},
{
Name: "daemon-bus-graph",
ShortUsage: "tailscale debug daemon-bus-graph",
Exec: runDaemonBusGraph,
ShortHelp: "Print graph for the tailscaled bus",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("debug-bus-graph")
fs.StringVar(&daemonBusGraphArgs.format, "format", "json", "output format [json/dot]")
return fs
})(),
},
{
Name: "metrics",
ShortUsage: "tailscale debug metrics",
@ -807,6 +819,50 @@ func runDaemonBusEvents(ctx context.Context, args []string) error {
return nil
}
var daemonBusGraphArgs struct {
format string
}
func runDaemonBusGraph(ctx context.Context, args []string) error {
graph, err := localClient.EventBusGraph(ctx)
if err != nil {
return err
}
if format := daemonBusGraphArgs.format; format != "json" && format != "dot" {
return fmt.Errorf("unrecognized output format %q", format)
}
if daemonBusGraphArgs.format == "dot" {
var topics eventbus.DebugTopics
if err := json.Unmarshal(graph, &topics); err != nil {
return fmt.Errorf("unable to parse json: %w", err)
}
fmt.Print(generateDOTGraph(topics.Topics))
} else {
fmt.Print(string(graph))
}
return nil
}
// generateDOTGraph generates the DOT graph format based on the events
func generateDOTGraph(topics []eventbus.DebugTopic) string {
var sb strings.Builder
sb.WriteString("digraph event_bus {\n")
for _, topic := range topics {
// If no subscribers, still ensure the topic is drawn
if len(topic.Subscribers) == 0 {
topic.Subscribers = append(topic.Subscribers, "no-subscribers")
}
for _, subscriber := range topic.Subscribers {
fmt.Fprintf(&sb, "\t%q -> %q [label=%q];\n",
topic.Publisher, subscriber, cmp.Or(topic.Name, "???"))
}
}
sb.WriteString("}\n")
return sb.String()
}
var metricsArgs struct {
watch bool
}

View File

@ -93,6 +93,7 @@ var handler = map[string]LocalAPIHandler{
"component-debug-logging": (*Handler).serveComponentDebugLogging,
"debug": (*Handler).serveDebug,
"debug-bus-events": (*Handler).serveDebugBusEvents,
"debug-bus-graph": (*Handler).serveEventBusGraph,
"debug-derp-region": (*Handler).serveDebugDERPRegion,
"debug-dial-types": (*Handler).serveDebugDialTypes,
"debug-log": (*Handler).serveDebugLog,
@ -1004,6 +1005,55 @@ func (h *Handler) serveDebugBusEvents(w http.ResponseWriter, r *http.Request) {
}
}
// serveEventBusGraph taps into the event bus and dumps out the active graph of
// publishers and subscribers. It does not represent anything about the messages
// exchanged.
func (h *Handler) serveEventBusGraph(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.GET {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
bus, ok := h.LocalBackend().Sys().Bus.GetOK()
if !ok {
http.Error(w, "event bus not running", http.StatusPreconditionFailed)
return
}
debugger := bus.Debugger()
clients := debugger.Clients()
graph := map[string]eventbus.DebugTopic{}
for _, client := range clients {
for _, pub := range debugger.PublishTypes(client) {
topic, ok := graph[pub.Name()]
if !ok {
topic = eventbus.DebugTopic{Name: pub.Name()}
}
topic.Publisher = client.Name()
graph[pub.Name()] = topic
}
for _, sub := range debugger.SubscribeTypes(client) {
topic, ok := graph[sub.Name()]
if !ok {
topic = eventbus.DebugTopic{Name: sub.Name()}
}
topic.Subscribers = append(topic.Subscribers, client.Name())
graph[sub.Name()] = topic
}
}
// The top level map is not really needed for the client, convert to a list.
topics := eventbus.DebugTopics{}
for _, v := range graph {
topics.Topics = append(topics.Topics, v)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(topics)
}
func (h *Handler) serveComponentDebugLogging(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "debug access denied", http.StatusForbidden)

View File

@ -195,3 +195,16 @@ type DebugEvent struct {
To []string
Event any
}
// DebugTopics provides the JSON encoding as a wrapper for a collection of [DebugTopic].
type DebugTopics struct {
Topics []DebugTopic
}
// DebugTopic provides the JSON encoding of publishers and subscribers for a
// given topic.
type DebugTopic struct {
Name string
Publisher string
Subscribers []string
}