Repository: go-openapi/runtime
Pull request #190 (2020-11-28 09:50:34)

add optional OpenTracing support for client
adds a wrapper for runtime.ClientTransport that adds OpenTracing support for all requests.

fredbi: :100:  
codecov[bot]: # [Codecov](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=h1) Report
> Merging [#190](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=desc) (4f0bd89) into [master](https://codecov.io/gh/go-openapi/runtime/commit/d6bcd5b0000c496449f44c5d686169e1b3d003c1?el=desc) (d6bcd5b) will **decrease** coverage by `0.09%`.
> The diff coverage is `75.38%`.

[![Impacted file tree graph](https://codecov.io/gh/go-openapi/runtime/pull/190/graphs/tree.svg?width=650&height=150&src=pr&token=6wl4NsM8aQ)](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #190      +/-   ##
==========================================
- Coverage   80.00%   79.91%   -0.10%     
==========================================
  Files          42       43       +1     
  Lines        2126     2191      +65     
==========================================
+ Hits         1701     1751      +50     
- Misses        323      338      +15     
  Partials      102      102              
```


| [Impacted Files](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [client\_request.go](https://codecov.io/gh/go-openapi/runtime/pull/190/diff?src=pr&el=tree#diff-Y2xpZW50X3JlcXVlc3QuZ28=) | `25.00% <33.33%> (+15.00%)` | :arrow_up: |
| [client/opentracing.go](https://codecov.io/gh/go-openapi/runtime/pull/190/diff?src=pr&el=tree#diff-Y2xpZW50L29wZW50cmFjaW5nLmdv) | `91.48% <91.48%> (ø)` | |
| [client/auth\_info.go](https://codecov.io/gh/go-openapi/runtime/pull/190/diff?src=pr&el=tree#diff-Y2xpZW50L2F1dGhfaW5mby5nbw==) | `94.11% <0.00%> (+5.88%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=footer). Last update [d6bcd5b...4f0bd89](https://codecov.io/gh/go-openapi/runtime/pull/190?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

zeitlinger: Is there a better way to get the peer hostname, e.g. from the request?
fredbi: > Is there a better way to get the peer hostname, e.g. from the request?

I assume this is the hostname of the caller. This is mostly okay like this. Could default to `os.Hostname()` is left empty.

zeitlinger: No, the remote hostname

On Sat, Nov 28, 2020, 12:51 fredbi <notifications@github.com> wrote:

> Is there a better way to get the peer hostname, e.g. from the request?
>
> I assume this is the hostname of the caller. This is mostly okay like
> this. Could default to os.Hostname() is left empty.
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/go-openapi/runtime/pull/190#issuecomment-735221573>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAVTR46PXN2WJKC3MNIKOLTSSDP2PANCNFSM4UFWABBQ>
> .
>

fredbi: The remote hostname is stored by the client:
https://github.com/go-openapi/runtime/blob/d6bcd5b0000c496449f44c5d686169e1b3d003c1/client/runtime.go#L228

That being said, if I were some ops wiling to monitor traces emitted by the client, I'd rather have the host of the emitter (supposedly, I'll get another span from the server side with the same traceID).
zeitlinger: > That being said, if I were some ops wiling to monitor traces emitted by the client, I'd rather have the host of the emitter (supposedly, I'll get another span from the server side with the same traceID).

the host of the emitter is usually set as a default tag when initializing the tracer, which is vender dependent.

fredbi: > the host of the emitter is usually set as a default tag when initializing the tracer, which is vender dependent.
ok got it

Patch:
diff --git a/client/opentracing.go b/client/opentracing.go
new file mode 100644
index 0000000..87e0753
--- /dev/null
+++ b/client/opentracing.go
@@ -0,0 +1,98 @@
+package client
+
+import (
+	"net/http"
+	"regexp"
+	"strings"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/opentracing/opentracing-go"
+	"github.com/opentracing/opentracing-go/ext"
+	"github.com/opentracing/opentracing-go/log"
+
+	"github.com/go-openapi/runtime"
+)
+
+type tracingTransport struct {
+	transport runtime.ClientTransport
+	host      string
+	opts      []opentracing.StartSpanOption
+}
+
+func newOpenTracingTransport(transport runtime.ClientTransport, host string, opts []opentracing.StartSpanOption,
+) runtime.ClientTransport {
+	return &tracingTransport{
+		transport: transport,
+		host:      host,
+		opts:      opts,
+	}
+}
+
+func (t *tracingTransport) Submit(op *runtime.ClientOperation) (interface{}, error) {
+	authInfo := op.AuthInfo
+	reader := op.Reader
+
+	var span opentracing.Span
+	defer func() {
+		if span != nil {
+			span.Finish()
+		}
+	}()
+
+	op.AuthInfo = runtime.ClientAuthInfoWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
+		span = createClientSpan(op, req.GetHeaderParams(), t.host, t.opts)
+		return authInfo.AuthenticateRequest(req, reg)
+	})
+
+	op.Reader = runtime.ClientResponseReaderFunc(func(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+		if span != nil {
+			code := response.Code()
+			ext.HTTPStatusCode.Set(span, uint16(code))
+			if code >= 400 {
+				ext.Error.Set(span, true)
+			}
+		}
+		return reader.ReadResponse(response, consumer)
+	})
+
+	submit, err := t.transport.Submit(op)
+	if err != nil && span != nil {
+		ext.Error.Set(span, true)
+		span.LogFields(log.Error(err))
+	}
+	return submit, err
+}
+
+func createClientSpan(op *runtime.ClientOperation, header http.Header, host string,
+	opts []opentracing.StartSpanOption) opentracing.Span {
+	ctx := op.Context
+	span := opentracing.SpanFromContext(ctx)
+
+	if span != nil {
+		opts = append(opts, ext.SpanKindRPCClient)
+		span, _ = opentracing.StartSpanFromContextWithTracer(
+			ctx, span.Tracer(), toSnakeCase(op.ID), opts...)
+
+		ext.Component.Set(span, "go-openapi")
+		ext.PeerHostname.Set(span, host)
+		span.SetTag("http.path", op.PathPattern)
+		ext.HTTPMethod.Set(span, op.Method)
+
+		_ = span.Tracer().Inject(
+			span.Context(),
+			opentracing.HTTPHeaders,
+			opentracing.HTTPHeadersCarrier(header))
+
+		return span
+	}
+	return nil
+}
+
+var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
+var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
+
+func toSnakeCase(str string) string {
+	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
+	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
+	return strings.ToLower(snake)
+}
diff --git a/client/runtime.go b/client/runtime.go
index 083d751..069fcd5 100644
--- a/client/runtime.go
+++ b/client/runtime.go
@@ -32,6 +32,7 @@ import (
 	"time"
 
 	"github.com/go-openapi/strfmt"
+	"github.com/opentracing/opentracing-go"
 
 	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/logger"
@@ -292,6 +293,14 @@ func NewWithClient(host, basePath string, schemes []string, client *http.Client)
 	return rt
 }
 
+// WithOpenTracing adds opentracing support to the provided runtime.
+// A new client span is created for each request.
+// If the context of the client operation does not contain an active span, no span is created.
+// The provided opts are applied to each spans - for example to add global tags.
+func (r *Runtime) WithOpenTracing(opts ...opentracing.StartSpanOption) runtime.ClientTransport {
+	return newOpenTracingTransport(r, r.Host, opts)
+}
+
 func (r *Runtime) pickScheme(schemes []string) string {
 	if v := r.selectScheme(r.schemes); v != "" {
 		return v
diff --git a/client_request.go b/client_request.go
index 6215e0a..3efda34 100644
--- a/client_request.go
+++ b/client_request.go
@@ -101,3 +101,53 @@ func (n *namedReadCloser) Read(p []byte) (int, error) {
 func (n *namedReadCloser) Name() string {
 	return n.name
 }
+
+type TestClientRequest struct {
+	Headers http.Header
+	Body    interface{}
+}
+
+func (t *TestClientRequest) SetHeaderParam(name string, values ...string) error {
+	if t.Headers == nil {
+		t.Headers = make(http.Header)
+	}
+	t.Headers.Set(name, values[0])
+	return nil
+}
+
+func (t *TestClientRequest) SetQueryParam(_ string, _ ...string) error { return nil }
+
+func (t *TestClientRequest) SetFormParam(_ string, _ ...string) error { return nil }
+
+func (t *TestClientRequest) SetPathParam(_ string, _ string) error { return nil }
+
+func (t *TestClientRequest) SetFileParam(_ string, _ ...NamedReadCloser) error { return nil }
+
+func (t *TestClientRequest) SetBodyParam(body interface{}) error {
+	t.Body = body
+	return nil
+}
+
+func (t *TestClientRequest) SetTimeout(time.Duration) error {
+	return nil
+}
+
+func (t *TestClientRequest) GetQueryParams() url.Values { return nil }
+
+func (t *TestClientRequest) GetMethod() string { return "" }
+
+func (t *TestClientRequest) GetPath() string { return "" }
+
+func (t *TestClientRequest) GetBody() []byte { return nil }
+
+func (t *TestClientRequest) GetBodyParam() interface{} {
+	return t.Body
+}
+
+func (t *TestClientRequest) GetFileParam() map[string][]NamedReadCloser {
+	return nil
+}
+
+func (t *TestClientRequest) GetHeaderParams() http.Header {
+	return t.Headers
+}
diff --git a/go.mod b/go.mod
index 3effc28..6fcf0fe 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/go-openapi/strfmt v0.19.5
 	github.com/go-openapi/swag v0.19.9
 	github.com/go-openapi/validate v0.19.10
+	github.com/opentracing/opentracing-go v1.2.0
 	github.com/stretchr/testify v1.6.1
 	gopkg.in/yaml.v2 v2.3.0
 	gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
diff --git a/go.sum b/go.sum
index 5cadebb..27801d8 100644
--- a/go.sum
+++ b/go.sum
@@ -168,6 +168,8 @@ github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
 github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=


Test patch:
diff --git a/authinfo_test.go b/authinfo_test.go
index 0768df3..babed85 100644
--- a/authinfo_test.go
+++ b/authinfo_test.go
@@ -26,7 +26,7 @@ func TestAuthInfoWriter(t *testing.T) {
 		return r.SetHeaderParam("authorization", "Bearer the-token-goes-here")
 	})
 
-	tr := new(trw)
+	tr := new(TestClientRequest)
 	err := hand.AuthenticateRequest(tr, nil)
 	assert.NoError(t, err)
 	assert.Equal(t, "Bearer the-token-goes-here", tr.Headers.Get("Authorization"))
diff --git a/client/opentracing_test.go b/client/opentracing_test.go
new file mode 100644
index 0000000..db1922a
--- /dev/null
+++ b/client/opentracing_test.go
@@ -0,0 +1,102 @@
+package client
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"io/ioutil"
+	"testing"
+
+	"github.com/opentracing/opentracing-go"
+	"github.com/opentracing/opentracing-go/ext"
+	"github.com/opentracing/opentracing-go/mocktracer"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/go-openapi/runtime"
+)
+
+type tres struct {
+}
+
+func (r tres) Code() int {
+	return 490
+}
+func (r tres) Message() string {
+	return "the message"
+}
+func (r tres) GetHeader(_ string) string {
+	return "the header"
+}
+func (r tres) Body() io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewBufferString("the content"))
+}
+
+type mockRuntime struct {
+	req runtime.TestClientRequest
+}
+
+func (m *mockRuntime) Submit(operation *runtime.ClientOperation) (interface{}, error) {
+	_ = operation.AuthInfo.AuthenticateRequest(&m.req, nil)
+	_, _ = operation.Reader.ReadResponse(&tres{}, nil)
+	return nil, nil
+}
+
+func testOperation(ctx context.Context) *runtime.ClientOperation {
+	return &runtime.ClientOperation{
+		ID:                 "getCluster",
+		Method:             "GET",
+		PathPattern:        "/kubernetes-clusters/{cluster_id}",
+		ProducesMediaTypes: []string{"application/json"},
+		ConsumesMediaTypes: []string{"application/json"},
+		Schemes:            []string{"https"},
+		Reader: runtime.ClientResponseReaderFunc(func(runtime.ClientResponse, runtime.Consumer) (interface{}, error) {
+			return nil, nil
+		}),
+		AuthInfo: PassThroughAuth,
+		Context:  ctx,
+	}
+}
+
+func Test_TracingRuntime_submit(t *testing.T) {
+	t.Parallel()
+	tracer := mocktracer.New()
+	_, ctx := opentracing.StartSpanFromContextWithTracer(context.Background(), tracer, "op")
+
+	header := map[string][]string{}
+	r := newOpenTracingTransport(&mockRuntime{runtime.TestClientRequest{Headers: header}},
+		"remote_host",
+		[]opentracing.StartSpanOption{opentracing.Tag{
+			Key:   string(ext.PeerService),
+			Value: "service",
+		}})
+
+	_, err := r.Submit(testOperation(ctx))
+	require.NoError(t, err)
+
+	if assert.Len(t, tracer.FinishedSpans(), 1) {
+		span := tracer.FinishedSpans()[0]
+		assert.Equal(t, "get_cluster", span.OperationName)
+		assert.Equal(t, map[string]interface{}{
+			"component":        "go-openapi",
+			"http.method":      "GET",
+			"http.path":        "/kubernetes-clusters/{cluster_id}",
+			"http.status_code": uint16(490),
+			"peer.hostname":    "remote_host",
+			"peer.service":     "service",
+			"span.kind":        ext.SpanKindRPCClientEnum,
+			"error":            true,
+		}, span.Tags())
+	}
+}
+
+func Test_injectSpanContext(t *testing.T) {
+	t.Parallel()
+	tracer := mocktracer.New()
+	_, ctx := opentracing.StartSpanFromContextWithTracer(context.Background(), tracer, "op")
+	header := map[string][]string{}
+	createClientSpan(testOperation(ctx), header, "", nil)
+
+	// values are random - just check that something was injected
+	assert.Len(t, header, 3)
+}
diff --git a/client_request_test.go b/client_request_test.go
index 7daaa12..1de7a1b 100644
--- a/client_request_test.go
+++ b/client_request_test.go
@@ -15,65 +15,12 @@
 package runtime
 
 import (
-	"net/http"
-	"net/url"
 	"testing"
-	"time"
 
 	"github.com/go-openapi/strfmt"
 	"github.com/stretchr/testify/assert"
 )
 
-type trw struct {
-	Headers http.Header
-	Body    interface{}
-}
-
-func (t *trw) SetHeaderParam(name string, values ...string) error {
-	if t.Headers == nil {
-		t.Headers = make(http.Header)
-	}
-	t.Headers.Set(name, values[0])
-	return nil
-}
-
-func (t *trw) SetQueryParam(_ string, _ ...string) error { return nil }
-
-func (t *trw) SetFormParam(_ string, _ ...string) error { return nil }
-
-func (t *trw) SetPathParam(_ string, _ string) error { return nil }
-
-func (t *trw) SetFileParam(_ string, _ ...NamedReadCloser) error { return nil }
-
-func (t *trw) SetBodyParam(body interface{}) error {
-	t.Body = body
-	return nil
-}
-
-func (t *trw) SetTimeout(timeout time.Duration) error {
-	return nil
-}
-
-func (t *trw) GetQueryParams() url.Values { return nil }
-
-func (t *trw) GetMethod() string { return "" }
-
-func (t *trw) GetPath() string { return "" }
-
-func (t *trw) GetBody() []byte { return nil }
-
-func (t *trw) GetBodyParam() interface{} {
-	return t.Body
-}
-
-func (t *trw) GetFileParam() map[string][]NamedReadCloser {
-	return nil
-}
-
-func (t *trw) GetHeaderParams() http.Header {
-	return t.Headers
-}
-
 func TestRequestWriterFunc(t *testing.T) {
 	hand := ClientRequestWriterFunc(func(r ClientRequest, reg strfmt.Registry) error {
 		_ = r.SetHeaderParam("blah", "blah blah")
@@ -81,7 +28,7 @@ func TestRequestWriterFunc(t *testing.T) {
 		return nil
 	})
 
-	tr := new(trw)
+	tr := new(TestClientRequest)
 	_ = hand.WriteToRequest(tr, nil)
 	assert.Equal(t, "blah blah", tr.Headers.Get("blah"))
 	assert.Equal(t, "Adriana", tr.Body.(struct{ Name string }).Name)