gtrace
Command line tool gtrace generates boilerplate code for Go components tracing (aka instrumentation).
Usage
As a developer of some module (whenever its library or application module) you should define trace points (or hooks) which user of your code can then initialize with some function (aka probes) during runtime.
TL;DR
gtrace suggests you to use structures (tagged with //gtrace:gen
) holding all hooks related to your component and generates helper functions around them so that you can merge such structures and call the hooks without any checks for nil
. It also can generate context aware helpers to call hooks associated with context.
Example of generated code is here.
Basic
Lets assume that we have some package called lib
and some lib.Client
structure which holds net.Conn
internally and pings it every time before making some request when user calls Client.Do()
.
For the sake of simplicity lets not cover how dial, ping or any other thing happens.
type Client struct {
conn net.Conn
}
func (c *Client) Do(ctx context.Context) error {
if err := c.ping(ctx); err != nil {
return err
}
// Some client logic.
}
func (c *Client) ping(ctx context.Context) error {
return doPing(ctx, c.conn)
}
What if we need to write some logs right before and after ping happens? There are several ways to do it, but with gtrace we start by defining trace points in our package:
package lib
type ClientTrace struct {
OnPing func() func(error)
}
type Client struct {
Trace ClientTrace
...
}
That is, we export hook functions for every code point that might be interesting for the user of our package. The ClientTrace
structure contains definitions of all trace points for the Client
. For this example it has only one point. It defines pair of ping start and ping done callbacks. A user of our package can use it like so:
c := lib.Client{
Trace: ClientTrace{
OnPing: func() {
log.Println("ping start")
return func(err error) {
log.Printf("ping done; err=%v", err)
}
},
},
}
How the Client
should call that hooks? Well, thats the one of the reason of gtrace exists: it generates few useful (and very annoying to be manually typed) helpers to use this tracing approach. Lets do this:
package lib
//go:generate gtrace
//gtrace:gen
type ClientTrace struct {
OnPing func() func(error)
}
And after go generate
we can instrument our pinging facility as this:
func (c *Client) ping(ctx context.Context) error {
done := c.Trace.onPing() // added this line.
err := doPing(ctx, c.conn)
done(err) // and this line.
return err
}
grace has generated that lib.Client.onPing()
non-exported method which checks if appopriate probe function is non-nil (as well as the returned ping done callback). If any of the callbacks is nil it returns noop functions to avoid branching in the Client.ping()
code.
Composing
Lets return to the user of our package and cover another feature that gtrace generates for us: trace points composing. Composing is about merging two structures of the same trace and resulting a third one which calls hooks from both of them. It is useful when user wants to instrument our ping facility with different measure types (to write logs as well as measure call latency):
var t ClientTrace
t = t.Compose(ClientTrace{
OnPing: func() {
log.Println("ping start")
return func(err error) {
log.Printf("ping done; err=%v", err)
}
},
})
t = t.Compose(ClientTrace{
OnPing: func() {
start := time.Now()
return func(error) {
sendLatency(time.Since(start))
}
},
})
c := lib.Client{
Trace: t,
}
Context
Trace points composing gives us additional way to instrument our package: a context based tracing. We can setup ClientTrace
not for the whole Client
, but for some particular context (and probably do this on some particular condition). To do this we should ask gtrace to generate context aware tracing:
//gtrace:gen
//gtrace:set context
type ClientTrace struct {
OnPing func() func(error)
}
After go generate
command signature of lib.Client.onPing
changed to onPing(context.Context)
, as well as two additional exported functions added: lib.WithClientTrace()
and lib.ContextClientTrace()
. The former is to associate some ClientTrace
with some context; and the latter is to obtain associated ClientTrace
from context. So on the Client
side we should only pass the context to the onPing()
method:
func (c *Client) ping(ctx context.Context) error {
done := c.Trace.onPing(ctx) // this line has changed.
err := doPing(ctx, c.conn)
done(err)
return err
}
And on the user side we can do this:
c := lib.Client{
Trace: t, // Note that both traces are used.
}
// Send 100 requests with every 5th being instrumented additionally.
for i := 0; i < 100; i++ {
ctx := context.Background()
if i % 5 == 0 {
ctx = lib.WithClientTrace(ctx, ClientTrace{
...
})
}
if err := c.Do(ctx); err != nil {
// handle error.
}
}
Shortcuts
Thats it for basic tracing. But usually trace points define hooks with number of arguments way bigger than one or two. In that case we can declare a struct holding all hook's arguments instead:
type ClientTrace struct {
OnPing func(ClientTracePingStart) func(ClientTracePingDone)
}
This makes hooks more readable and extensible. But it also makes calling such hooks a bit more verbose:
func (c *Client) ping(ctx context.Context) error {
done := c.Trace.onPing(ClientTracePingStart{
Foo: 1,
Bar: 2,
Baz: 3,
})
err := doPing(ctx, c.conn)
done(ClientTracePingDone{
Foo: 1,
Bar: 2,
Baz: 3,
Err: err,
})
return err
}
gtrace can generate functions called shortcuts to call the hook in more "flat" way:
//gtrace:gen
//gtrace:set shortcut
type ClientTrace struct {
OnPing func(ClientTracePingStart) func(ClientTracePingDone)
}
After go generate
we able to call hooks like this:
func (c *Client) ping(ctx context.Context) error {
done := clientTraceOnPing(c.Trace, 1, 2, 3)
err := doPing(ctx, c.conn)
done(1, 2, 3, err)
return err
}
Build Tags
gtrace can generate zero-cost tracing helpers when tracing of your app is optional. That is, your client code will remain the same -- composing traces with needed callbacks, calling non-exported versions of hooks (or shortcuts) etc. But after compilation calling the tracing helpers would take no CPU time.
To do that, you can pass the -tag
flag to gtrace
binary, which will result generation of two _gtrace
files -- one which will be used when compiling with -tags gtrace
, and one with stubs.
NOTE: gtrace also respects build constraints for GOOS and GOARCH.
Examples
For more details feel free to read the examples
package of this repo as well as delve into test/test_grace.go
.