subtypes,handlers: add runtime trace regions (#4060)

* subtypes,handlers: add runtime trace regions

The subtype.Filterable and handlers.ProtoToStruct functions
are costly and worth measuring whenever runtime tracing
is in use. This has almost no cost when tracing is not in use,
so there's little harm in having them in place.

* testing: Add TRACING.md

TRACING.md contains information about how to perform
Go runtime tracing of the Boundary application.
pull/4063/head
Johan Brandhorst-Satzkorn 2 years ago committed by GitHub
parent 18729721a1
commit a89573b35f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -187,7 +187,7 @@ func (s Service) ListAccounts(ctx context.Context, req *pbs.ListAccountsRequest)
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -196,7 +196,7 @@ func (s Service) ListAuthMethods(ctx context.Context, req *pbs.ListAuthMethodsRe
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}
@ -1387,7 +1387,7 @@ func (s Service) convertToAuthenticateResponse(ctx context.Context, req *pbs.Aut
}, nil
}
func transformAuthenticateRequestAttributes(msg proto.Message) error {
func transformAuthenticateRequestAttributes(ctx context.Context, msg proto.Message) error {
const op = "authmethod.transformAuthenticateRequestAttributes"
authRequest, ok := msg.(*pbs.AuthenticateRequest)
if !ok {
@ -1451,7 +1451,7 @@ func transformAuthenticateRequestAttributes(msg proto.Message) error {
return nil
}
func transformAuthenticateResponseAttributes(msg proto.Message) error {
func transformAuthenticateResponseAttributes(ctx context.Context, msg proto.Message) error {
const op = "authmethod.transformAuthenticateResponseAttributes"
authResponse, ok := msg.(*pbs.AuthenticateResponse)
if !ok {
@ -1468,22 +1468,22 @@ func transformAuthenticateResponseAttributes(msg proto.Message) error {
// No transformation necessary
newAttrs = attrs.Attributes
case *pbs.AuthenticateResponse_AuthTokenResponse:
newAttrs, err = handlers.ProtoToStruct(attrs.AuthTokenResponse)
newAttrs, err = handlers.ProtoToStruct(ctx, attrs.AuthTokenResponse)
if err != nil {
return err
}
case *pbs.AuthenticateResponse_OidcAuthMethodAuthenticateStartResponse:
newAttrs, err = handlers.ProtoToStruct(attrs.OidcAuthMethodAuthenticateStartResponse)
newAttrs, err = handlers.ProtoToStruct(ctx, attrs.OidcAuthMethodAuthenticateStartResponse)
if err != nil {
return err
}
case *pbs.AuthenticateResponse_OidcAuthMethodAuthenticateCallbackResponse:
newAttrs, err = handlers.ProtoToStruct(attrs.OidcAuthMethodAuthenticateCallbackResponse)
newAttrs, err = handlers.ProtoToStruct(ctx, attrs.OidcAuthMethodAuthenticateCallbackResponse)
if err != nil {
return err
}
case *pbs.AuthenticateResponse_OidcAuthMethodAuthenticateTokenResponse:
newAttrs, err = handlers.ProtoToStruct(attrs.OidcAuthMethodAuthenticateTokenResponse)
newAttrs, err = handlers.ProtoToStruct(ctx, attrs.OidcAuthMethodAuthenticateTokenResponse)
if err != nil {
return err
}

@ -4,6 +4,7 @@
package authmethods
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
@ -196,7 +197,7 @@ func TestTransformAuthenticateRequestAttributes(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
in := proto.Clone(c.input)
require.NoError(t, transformAuthenticateRequestAttributes(in))
require.NoError(t, transformAuthenticateRequestAttributes(context.Background(), in))
require.Empty(t, cmp.Diff(c.expected, in, protocmp.Transform()))
})
}
@ -206,11 +207,11 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
t.Parallel()
t.Run("not-an-authenticate-request", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pb.AuthMethod{}))
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pb.AuthMethod{}))
})
t.Run("invalid-auth-method-id", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "invalid",
Attrs: &pbs.AuthenticateRequest_Attributes{
Attributes: &structpb.Struct{},
@ -219,7 +220,7 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
})
t.Run("invalid-oidc-command", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "amoidc_test",
Command: "invalid",
Attrs: &pbs.AuthenticateRequest_Attributes{
@ -229,7 +230,7 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
})
t.Run("invalid-password-attributes", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "apw_test",
Attrs: &pbs.AuthenticateRequest_Attributes{
Attributes: &structpb.Struct{
@ -243,7 +244,7 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
})
t.Run("invalid-ldap-attributes", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "amldap_test",
Attrs: &pbs.AuthenticateRequest_Attributes{
Attributes: &structpb.Struct{
@ -257,7 +258,7 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
})
t.Run("invalid-oidc-start-attributes", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "amoidc_test",
Command: "start",
Attrs: &pbs.AuthenticateRequest_Attributes{
@ -272,7 +273,7 @@ func TestTransformAuthenticateRequestAttributesErrors(t *testing.T) {
})
t.Run("invalid-oidc-token-attributes", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateRequestAttributes(&pbs.AuthenticateRequest{
require.Error(t, transformAuthenticateRequestAttributes(context.Background(), &pbs.AuthenticateRequest{
AuthMethodId: "amoidc_test",
Command: "token",
Attrs: &pbs.AuthenticateRequest_Attributes{
@ -421,7 +422,7 @@ func TestTransformAuthenticateResponseAttributes(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
in := proto.Clone(c.input)
require.NoError(t, transformAuthenticateResponseAttributes(in))
require.NoError(t, transformAuthenticateResponseAttributes(context.Background(), in))
require.Empty(t, cmp.Diff(c.expected, in, protocmp.Transform()))
})
}
@ -431,6 +432,6 @@ func TestTransformAuthenticateResponseAttributesErrors(t *testing.T) {
t.Parallel()
t.Run("not-an-authenticate-response", func(t *testing.T) {
t.Parallel()
require.Error(t, transformAuthenticateResponseAttributes(&pb.AuthMethod{}))
require.Error(t, transformAuthenticateResponseAttributes(context.Background(), &pb.AuthMethod{}))
})
}

@ -177,7 +177,7 @@ func (s Service) ListCredentialLibraries(ctx context.Context, req *pbs.ListCrede
return nil, err
}
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -160,7 +160,7 @@ func (s Service) ListCredentials(ctx context.Context, req *pbs.ListCredentialsRe
return nil, err
}
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -194,7 +194,7 @@ func (s Service) ListCredentialStores(ctx context.Context, req *pbs.ListCredenti
return nil, err
}
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -212,7 +212,7 @@ func (s Service) ListHostCatalogs(ctx context.Context, req *pbs.ListHostCatalogs
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -166,7 +166,7 @@ func (s Service) ListHostSetsWithOptions(ctx context.Context, req *pbs.ListHostS
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -147,7 +147,7 @@ func (s Service) ListHosts(ctx context.Context, req *pbs.ListHostsRequest) (*pbs
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -159,7 +159,7 @@ func (s Service) ListManagedGroups(ctx context.Context, req *pbs.ListManagedGrou
// This comes last so that we can use item fields in the filter after
// the allowed fields are populated above
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -22,7 +22,7 @@ import (
func TestOutgoingSplitCookie(t *testing.T) {
rec := httptest.NewRecorder()
attrs, err := ProtoToStruct(&pba.AuthToken{Token: "t_abc_1234567890"})
attrs, err := ProtoToStruct(context.Background(), &pba.AuthToken{Token: "t_abc_1234567890"})
require.NoError(t, err)
require.NoError(t, OutgoingResponseFilter(context.Background(), rec, &pbs.AuthenticateResponse{Attrs: &pbs.AuthenticateResponse_Attributes{Attributes: attrs}, Type: "cookie"}))
assert.ElementsMatch(t, rec.Result().Cookies(), []*http.Cookie{

@ -4,6 +4,9 @@
package handlers
import (
"context"
"runtime/trace"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
@ -30,7 +33,8 @@ func StructToProto(fields *structpb.Struct, p proto.Message, opt ...Option) erro
return nil
}
func ProtoToStruct(p proto.Message) (*structpb.Struct, error) {
func ProtoToStruct(ctx context.Context, p proto.Message) (*structpb.Struct, error) {
defer trace.StartRegion(ctx, "subtypes.ProtoToStruct").End()
js, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(p)
if err != nil {
return nil, err

@ -4,6 +4,7 @@
package handlers
import (
"context"
"testing"
structpb "github.com/golang/protobuf/ptypes/struct"
@ -49,7 +50,7 @@ func TestStructToProtoToStruct(t *testing.T) {
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
st, err := ProtoToStruct(tc.pb)
st, err := ProtoToStruct(context.Background(), tc.pb)
require.NoError(t, err)
wantStruct := &structpb.Struct{}

@ -104,6 +104,7 @@ func dynamicToSessionCredential(ctx context.Context, cred credential.Dynamic) (*
switch c := cred.(type) {
case credential.UsernamePassword:
credData, err = handlers.ProtoToStruct(
ctx,
&pb.UsernamePasswordCredential{
Username: c.Username(),
Password: string(c.Password()),
@ -115,6 +116,7 @@ func dynamicToSessionCredential(ctx context.Context, cred credential.Dynamic) (*
case credential.SshPrivateKey:
credData, err = handlers.ProtoToStruct(
ctx,
&pb.SshPrivateKeyCredential{
Username: c.Username(),
PrivateKey: string(c.PrivateKey()),
@ -196,6 +198,7 @@ func staticToSessionCredential(ctx context.Context, cred credential.Static) (*pb
var err error
credType = string(globals.UsernamePasswordCredentialType)
credData, err = handlers.ProtoToStruct(
ctx,
&pb.UsernamePasswordCredential{
Username: c.GetUsername(),
Password: string(c.GetPassword()),
@ -213,6 +216,7 @@ func staticToSessionCredential(ctx context.Context, cred credential.Static) (*pb
var err error
credType = string(globals.SshPrivateKeyCredentialType)
credData, err = handlers.ProtoToStruct(
ctx,
&pb.SshPrivateKeyCredential{
Username: c.GetUsername(),
PrivateKey: string(c.GetPrivateKey()),

@ -258,7 +258,7 @@ func (s Service) ListTargets(ctx context.Context, req *pbs.ListTargetsRequest) (
return nil, err
}
filterable, err := subtypes.Filterable(item)
filterable, err := subtypes.Filterable(ctx, item)
if err != nil {
return nil, err
}

@ -4,7 +4,9 @@
package subtypes
import (
"context"
"fmt"
"runtime/trace"
"github.com/hashicorp/boundary/globals"
"github.com/hashicorp/boundary/internal/daemon/controller/handlers"
@ -56,7 +58,7 @@ func convertAttributesToSubtype(msg proto.Message, st globals.Subtype) error {
return nil
}
func convertAttributesToDefault(msg proto.Message, st globals.Subtype) error {
func convertAttributesToDefault(ctx context.Context, msg proto.Message, st globals.Subtype) error {
r := msg.ProtoReflect()
d := r.Descriptor()
@ -86,7 +88,7 @@ func convertAttributesToDefault(msg proto.Message, st globals.Subtype) error {
if !ok {
return fmt.Errorf("found subtype attribute field that is not proto.Message: %s %s", d.FullName(), stAttrField.FullName())
}
defaultAttrs, err := handlers.ProtoToStruct(stAttrs)
defaultAttrs, err := handlers.ProtoToStruct(ctx, stAttrs)
if err != nil {
return err
}
@ -113,7 +115,8 @@ func convertAttributesToDefault(msg proto.Message, st globals.Subtype) error {
//
// If the message does not conform to this structure,
// the original message is returned.
func Filterable(item proto.Message) (proto.Message, error) {
func Filterable(ctx context.Context, item proto.Message) (proto.Message, error) {
defer trace.StartRegion(ctx, "subtypes.Filterable").End()
clone := proto.Clone(item)
r := clone.ProtoReflect()
@ -138,13 +141,13 @@ func Filterable(item proto.Message) (proto.Message, error) {
}
attr = r.Get(oneofField).Message().Interface()
pbAttrs, err = handlers.ProtoToStruct(attr)
pbAttrs, err = handlers.ProtoToStruct(ctx, attr)
if err != nil {
return nil, err
}
r.Set(defaultAttrField, protoreflect.ValueOfMessage(pbAttrs.ProtoReflect()))
f, err := handlers.ProtoToStruct(r.Interface())
f, err := handlers.ProtoToStruct(ctx, r.Interface())
if err != nil {
return nil, err
}

@ -4,6 +4,7 @@
package subtypes
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
@ -99,7 +100,7 @@ func TestFilterable(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Filterable(tc.item)
got, err := Filterable(context.Background(), tc.item)
require.NoError(t, err)
require.Empty(t, cmp.Diff(got, tc.want, protocmp.Transform()))
})

@ -132,7 +132,7 @@ func transformRequestAttributes(req proto.Message) error {
return nil
}
func transformResponseItemAttributes(item proto.Message) error {
func transformResponseItemAttributes(ctx context.Context, item proto.Message) error {
r := item.ProtoReflect()
desc := r.Descriptor()
@ -154,13 +154,13 @@ func transformResponseItemAttributes(item proto.Message) error {
}
st := globals.Subtype(item.ProtoReflect().Get(typeField).String())
return convertAttributesToDefault(item, st)
return convertAttributesToDefault(ctx, item, st)
}
func transformRequest(msg proto.Message) error {
func transformRequest(ctx context.Context, msg proto.Message) error {
fqn := msg.ProtoReflect().Descriptor().FullName()
if fn, ok := globalTransformationRegistry.requestTransformationFuncs[fqn]; ok {
return fn(msg)
return fn(ctx, msg)
}
return transformRequestAttributes(msg)
}
@ -195,7 +195,7 @@ func transformRequest(msg proto.Message) error {
// // other subtype attributes types
// }
// }
func transformResponseAttributes(res proto.Message) error {
func transformResponseAttributes(ctx context.Context, res proto.Message) error {
r := res.ProtoReflect()
fields := r.Descriptor().Fields()
@ -208,7 +208,7 @@ func transformResponseAttributes(res proto.Message) error {
}
item := r.Get(itemField).Message().Interface()
return transformResponseItemAttributes(item)
return transformResponseItemAttributes(ctx, item)
case itemsField != nil:
if !itemsField.IsList() {
return nil
@ -217,7 +217,7 @@ func transformResponseAttributes(res proto.Message) error {
for i := 0; i < items.Len(); i++ {
item := items.Get(i).Message().Interface()
if err := transformResponseItemAttributes(item); err != nil {
if err := transformResponseItemAttributes(ctx, item); err != nil {
return err
}
}
@ -225,12 +225,12 @@ func transformResponseAttributes(res proto.Message) error {
return nil
}
func transformResponse(msg proto.Message) error {
func transformResponse(ctx context.Context, msg proto.Message) error {
fqn := msg.ProtoReflect().Descriptor().FullName()
if fn, ok := globalTransformationRegistry.responseTransformationFuncs[fqn]; ok {
return fn(msg)
return fn(ctx, msg)
}
return transformResponseAttributes(msg)
return transformResponseAttributes(ctx, msg)
}
// AttributeTransformerInterceptor is a grpc server interceptor that will
@ -278,11 +278,11 @@ func transformResponse(msg proto.Message) error {
// This request will be transformed into:
//
// type:"password" password_attributes:{login_name:"tim"}
func AttributeTransformerInterceptor(_ context.Context) grpc.UnaryServerInterceptor {
func AttributeTransformerInterceptor(ctx context.Context) grpc.UnaryServerInterceptor {
const op = "subtypes.AttributeTransformInterceptor"
return func(interceptorCtx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if reqMsg, ok := req.(proto.Message); ok {
if err := transformRequest(reqMsg); err != nil {
if err := transformRequest(ctx, reqMsg); err != nil {
fieldErrs := map[string]string{
"attributes": "Attribute fields do not match the expected format.",
}
@ -299,7 +299,7 @@ func AttributeTransformerInterceptor(_ context.Context) grpc.UnaryServerIntercep
res, handlerErr := handler(interceptorCtx, req)
if res, ok := res.(proto.Message); ok {
if err := transformResponse(res); err != nil {
if err := transformResponse(ctx, res); err != nil {
return nil, handlers.ApiErrorWithCodeAndMessage(codes.Internal, "failed building attribute struct: %v", err)
}
}

@ -4,6 +4,7 @@
package subtypes
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
@ -304,7 +305,7 @@ func TestTransformRequestAttributes(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := transformRequest(tc.req)
err := transformRequest(context.Background(), tc.req)
require.NoError(t, err)
assert.Empty(t, cmp.Diff(tc.req, tc.expected, protocmp.Transform()))
})
@ -588,7 +589,7 @@ func TestTransformResponseAttributes(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := transformResponse(tc.resp)
err := transformResponse(context.Background(), tc.resp)
require.NoError(t, err)
assert.Empty(t, cmp.Diff(tc.resp, tc.expected, protocmp.Transform()))
})
@ -598,7 +599,7 @@ func TestTransformResponseAttributes(t *testing.T) {
func TestCustomTransformRequest(t *testing.T) {
RegisterRequestTransformationFunc(
&attribute.TestCustomTransformation{},
func(m proto.Message) error {
func(_ context.Context, m proto.Message) error {
msg, ok := m.(*attribute.TestCustomTransformation)
require.True(t, ok, "wrong message passed to request transformation callback")
if msg.SomeRandomId == "some_random_id" && msg.SecondaryId == "secondary_id" {
@ -634,7 +635,7 @@ func TestCustomTransformRequest(t *testing.T) {
},
}
err := transformRequest(request)
err := transformRequest(context.Background(), request)
require.NoError(t, err)
assert.Empty(t, cmp.Diff(request, expected, protocmp.Transform()))
}
@ -642,11 +643,11 @@ func TestCustomTransformRequest(t *testing.T) {
func TestCustomTransformResponse(t *testing.T) {
RegisterResponseTransformationFunc(
&attribute.TestCustomTransformation{},
func(m proto.Message) error {
func(_ context.Context, m proto.Message) error {
msg, ok := m.(*attribute.TestCustomTransformation)
require.True(t, ok, "wrong message passed to response transformation callback")
if msg.SomeRandomId == "some_random_id" && msg.SecondaryId == "secondary_id" {
newAttrs, err := handlers.ProtoToStruct(msg.GetSubResourceAttributes())
newAttrs, err := handlers.ProtoToStruct(context.Background(), msg.GetSubResourceAttributes())
require.NoError(t, err)
msg.Attrs = &attribute.TestCustomTransformation_Attributes{
Attributes: newAttrs,
@ -677,7 +678,7 @@ func TestCustomTransformResponse(t *testing.T) {
},
}
err := transformResponse(response)
err := transformResponse(context.Background(), response)
require.NoError(t, err)
assert.Empty(t, cmp.Diff(response, expected, protocmp.Transform()))
}

@ -65,7 +65,7 @@ func (r *transformationRegistry) registerResponseTransformationFunc(ctx context.
// TransformationFunc defines the signature used to transform
// protobuf message attributes. The proto.Message is mutated in place.
type TransformationFunc func(proto.Message) error
type TransformationFunc func(context.Context, proto.Message) error
// RegisterRequestTransformationFunc registers a transformation function for
// the provided message. The message should be used as a request parameter to

@ -0,0 +1,31 @@
# Tracing in Boundary
Boundary includes a small number of runtime tracing user regions, which can be used to see where Boundary spends its time during execution.
To create a trace, we first need to expose the pprof endpoint. It is disabled by default. Exposing the pprof endpoint is as simple as importing the correct package anywhere in Boundary:
```go
package anything
import (
_ "net/http/pprof"
)
```
This will create a new HTTP endpoint on `localhost:6060` of the running binary. As such, it's only accessible to the users on the same machine.
Remember to remove this import again once you're done testing.
To create a trace, one can use any tool that allows creating HTTP requests, e.g. `curl`. To create a 3 second trace:
```
$ curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=3
```
Traces are most interesting if they contain some request handling, so it is recommended to prepare some HTTP requests that trigger the behavior you want to understand that you can run while the trace is being collected.
Once you have a trace, you can view it using the `gotraceui` tool. See https://github.com/dominikh/gotraceui/ for installation instructions,
but for both Windows and Mac it's as simple as:
```
$ go run honnef.co/go/gotraceui/cmd/gotraceui@master trace.out
```
Loading…
Cancel
Save