Skip to content

Commit

Permalink
Add span.context.destination.*
Browse files Browse the repository at this point in the history
Add span.context fields:

 - destination.address
 - destination.port
 - destination.service.type
 - destination.service.name
 - destination.service.resource

Implement support for (client) HTTP,
Elasticsearch, PostgreSQL and MySQL
(via apmsql or GORM) spans.
  • Loading branch information
axw committed Dec 9, 2019
1 parent 545a860 commit 9dc010f
Show file tree
Hide file tree
Showing 25 changed files with 644 additions and 20 deletions.
34 changes: 33 additions & 1 deletion internal/apmhttputil/remoteaddr.go
Expand Up @@ -19,10 +19,42 @@ package apmhttputil

import (
"net/http"
"strconv"
)

// RemoteAddr returns the remote (peer) socket address for the HTTP request.
// RemoteAddr returns the remote (peer) socket address for req,
// a server HTTP request.
func RemoteAddr(req *http.Request) string {
remoteAddr, _ := splitHost(req.RemoteAddr)
return remoteAddr
}

// DestinationAddr returns the destination server address and port
// for req, a client HTTP request.
//
// If req.URL.Host contains a port it will be returned, and otherwise
// the default port according to req.URL.Scheme will be returned. If
// the included port is not a valid integer, or no port is included
// and the scheme is unknown, the returned port value will be zero.
func DestinationAddr(req *http.Request) (string, int) {
host, strport := splitHost(req.URL.Host)
var port int
if strport != "" {
port, _ = strconv.Atoi(strport)
} else {
port = SchemeDefaultPort(req.URL.Scheme)
}
return host, port
}

// SchemeDefaultPort returns the default port for the given URI scheme,
// if known, or 0 otherwise.
func SchemeDefaultPort(scheme string) int {
switch scheme {
case "http":
return 80
case "https":
return 443
}
return 0
}
23 changes: 23 additions & 0 deletions internal/apmhttputil/remoteaddr_test.go
Expand Up @@ -19,9 +19,11 @@ package apmhttputil_test

import (
"net/http"
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.elastic.co/apm/internal/apmhttputil"
)
Expand All @@ -35,3 +37,24 @@ func TestRemoteAddr(t *testing.T) {
req.Header.Set("X-Real-IP", "127.1.2.3")
assert.Equal(t, "::1", apmhttputil.RemoteAddr(req))
}

func TestDestinationAddr(t *testing.T) {
test := func(u, expectAddr string, expectPort int) {
t.Run(u, func(t *testing.T) {
url, err := url.Parse(u)
require.NoError(t, err)

addr, port := apmhttputil.DestinationAddr(&http.Request{URL: url})
assert.Equal(t, expectAddr, addr)
assert.Equal(t, expectPort, port)
})
}
test("http://127.0.0.1:80", "127.0.0.1", 80)
test("http://127.0.0.1", "127.0.0.1", 80)
test("https://127.0.0.1:443", "127.0.0.1", 443)
test("https://127.0.0.1", "127.0.0.1", 443)
test("https://[::1]", "::1", 443)
test("https://[::1]:1234", "::1", 1234)
test("gopher://gopher.invalid:70", "gopher.invalid", 70)
test("gopher://gopher.invalid", "gopher.invalid", 0) // default unknown
}
3 changes: 3 additions & 0 deletions internal/apmhttputil/url.go
Expand Up @@ -98,6 +98,9 @@ func splitHost(in string) (host, port string) {
}
host, port, err := net.SplitHostPort(in)
if err != nil {
if n := len(in); n > 1 && in[0] == '[' && in[n-1] == ']' {
in = in[1 : n-1]
}
return in, ""
}
return host, port
Expand Down
89 changes: 89 additions & 0 deletions model/marshal_fastjson.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions model/model.go
Expand Up @@ -263,6 +263,9 @@ type Span struct {

// SpanContext holds contextual information relating to the span.
type SpanContext struct {
// Destination holds information about a destination service.
Destination *SpanDestinationContext `json:"destination,omitempty"`

// Database holds contextual information for database
// operation spans.
Database *DatabaseSpanContext `json:"db,omitempty"`
Expand All @@ -274,6 +277,34 @@ type SpanContext struct {
Tags IfaceMap `json:"tags,omitempty"`
}

// SpanDestinationContext holds contextual information about the destination
// for a span that relates to an operation involving an external service.
type SpanDestinationContext struct {
// Address holds the network address of the destination service.
// This may be a hostname, FQDN, or (IPv4 or IPv6) network address.
Address string `json:"address,omitempty"`

// Port holds the network port for the destination service.
Port int `json:"port,omitempty"`

// Service holds additional destination service context.
Service *SpanDestinationServiceContext `json:"service,omitempty"`
}

// SpanDestinationServiceContext holds contextual information about a
// destination service,.
type SpanDestinationServiceContext struct {
// Type holds the destination service type.
Type string `json:"type,omitempty"`

// Name holds the destination service name.
Name string `json:"name,omitempty"`

// Resource identifies the destination service
// resource, e.g. a URI or message queue name.
Resource string `json:"resource,omitempty"`
}

// DatabaseSpanContext holds contextual information for database
// operation spans.
type DatabaseSpanContext struct {
Expand Down
8 changes: 8 additions & 0 deletions modelwriter.go
Expand Up @@ -147,6 +147,14 @@ func (w *modelWriter) buildModelSpan(out *model.Span, span *Span, sd *SpanData)
out.Duration = sd.Duration.Seconds() * 1000
out.Context = sd.Context.build()

// Copy the span type to context.destination.service.type.
//
// TODO(axw) see if we can omit the field entirely, having
// the server default to using span.type.
if out.Context != nil && out.Context.Destination != nil && out.Context.Destination.Service != nil {
out.Context.Destination.Service.Type = out.Type
}

w.modelStacktrace = appendModelStacktraceFrames(w.modelStacktrace, sd.stacktrace)
out.Stacktrace = w.modelStacktrace
w.setStacktraceContext(out.Stacktrace)
Expand Down
8 changes: 8 additions & 0 deletions module/apmelasticsearch/client.go
Expand Up @@ -32,6 +32,10 @@ import (
"go.elastic.co/apm/module/apmhttp"
)

const (
defaultElasticsearchPortSuffix = ":9200"
)

// WrapRoundTripper returns an http.RoundTripper wrapping r, reporting each
// request as a span to Elastic APM, if the request's context contains a
// sampled transaction.
Expand Down Expand Up @@ -79,6 +83,10 @@ func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
ctx = apm.ContextWithSpan(ctx, span)
req = apmhttp.RequestWithContext(ctx, req)
span.Context.SetHTTPRequest(req)
span.Context.SetDestinationService(apm.DestinationServiceSpanContext{
Name: "elasticsearch",
Resource: "elasticsearch",
})
span.Context.SetDatabase(apm.DatabaseSpanContext{
Type: "elasticsearch",
Statement: statement,
Expand Down
32 changes: 32 additions & 0 deletions module/apmelasticsearch/client_test.go
Expand Up @@ -260,6 +260,38 @@ func TestStatementBodyGzipContentEncoding(t *testing.T) {
}, spans[0].Context.Database)
}

func TestDestinationAddress(t *testing.T) {
var rt roundTripperFunc = func(req *http.Request) (*http.Response, error) {
return httptest.NewRecorder().Result(), nil
}
client := &http.Client{Transport: apmelasticsearch.WrapRoundTripper(rt)}

test := func(url, destinationAddr string, destinationPort int) {
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
_, spans, _ := apmtest.WithTransaction(func(ctx context.Context) {
resp, err := client.Do(req.WithContext(ctx))
assert.NoError(t, err)
resp.Body.Close()
})
require.Len(t, spans, 1)
assert.Equal(t, &model.SpanDestinationContext{
Address: destinationAddr,
Port: destinationPort,
Service: &model.SpanDestinationServiceContext{
Type: "db",
Name: "elasticsearch",
Resource: "elasticsearch",
},
}, spans[0].Context.Destination)
}
test("http://host:9200/_search", "host", 9200)
test("http://host:80/_search", "host", 80)
test("http://127.0.0.1:9200/_search", "127.0.0.1", 9200)
test("http://[2001:db8::1]:9200/_search", "2001:db8::1", 9200)
test("http://[2001:db8::1]:80/_search", "2001:db8::1", 80)
}

type errorReadCloser struct {
readError error
closed bool
Expand Down
@@ -0,0 +1,84 @@
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// +build go1.11

package integration_test

import (
"context"
"net/http"
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

elasticsearch "github.com/elastic/go-elasticsearch/v7"

"go.elastic.co/apm/apmtest"
"go.elastic.co/apm/model"
"go.elastic.co/apm/module/apmelasticsearch"
)

func TestElastic(t *testing.T) {
if elasticsearchURL == "" {
t.Skipf("ELASTICSEARCH_URL not specified")
}

es, err := elasticsearch.NewClient(elasticsearch.Config{
// Addresses set from ELASTICSEARCH_URL
Transport: apmelasticsearch.WrapRoundTripper(http.DefaultTransport),
})
require.NoError(t, err)

_, spans, errs := apmtest.WithTransaction(func(ctx context.Context) {
res, err := es.Search(
es.Search.WithIndex("no_index"),
es.Search.WithContext(ctx),
es.Search.WithBody(strings.NewReader(`{"query":{"match_all":{}}}`)),
)
require.NoError(t, err)
res.Body.Close()
})
assert.Empty(t, errs)
require.Len(t, spans, 1)

esurl, err := url.Parse(elasticsearchURL)
require.NoError(t, err)
esurl.Path = "/no_index/_search"

// We test the value of destination in unit tests.
require.NotNil(t, spans[0].Context.Destination)
spans[0].Context.Destination = nil

assert.Equal(t, "Elasticsearch: GET no_index/_search", spans[0].Name)
assert.Equal(t, "db", spans[0].Type)
assert.Equal(t, "elasticsearch", spans[0].Subtype)
assert.Equal(t, "", spans[0].Action)
assert.Equal(t, &model.SpanContext{
Database: &model.DatabaseSpanContext{
Type: "elasticsearch",
Statement: `{"query":{"match_all":{}}}`,
},
HTTP: &model.HTTPSpanContext{
URL: esurl,
StatusCode: 404,
},
}, spans[0].Context)
}

0 comments on commit 9dc010f

Please sign in to comment.