Skip to content

Commit

Permalink
Implement and experimental tracing module
Browse files Browse the repository at this point in the history
* Implement trace-id generation logic

This commit implements trace-id generation based on the recommendations
of the W3C specification.

A lot of the code and logic present in this
commit are inherited and refactored from the xk6-distributed-tracing
project.

* Implement trace context propagators

This commit implements the trace-context propagation logic, and allows
for the creation of trace context headers in the W3C, B3 (openzipkin),
and Jaeger formats.

Note that a lot of the code and logic present in this commit are
inherited and refactored from the xk6-distributed-tracing project.

* Register a k6/experimental/tracing

Although we do not expose any of user-facing logic at this point, we
register the "k6/experimental/tracing" module in k6 to prepare for
further implementation of public APIs

* Define a tracing Client

This commit defines a Client type which exposes an API similar to the
HTTP module, and allows users to perform HTTP requests embedding a
trace-context, and attaching their trace_id as an output metadata to
their HTTP-related samples.

It uses the HTTP module's request function under the hood, and wraps it
with some "hook" logic to ensure the expected headers are present, and
that the expected output metadata are emitted.

The tracing client exposes an option-set allowing to use the three
available propagation format. Other options such as sampling, and
baggage are present for forward-compatibility, but will be implemented
at a later point in time.

* Expose the tracing client publicly as k6/experimental/tracing.Client

This commit exposes the Client constructor publicly as part of the
k6/experimental/tracing module. From this point forward users will be
able to instantiate the Client, and perform instrumented HTTP requests
using it.

This commit also adds a bunch of integration tests covering the expected
behavior of the module's API.

* fixup! return error instead of common.Throw in Client.Request

* fixup! make counters in tests atomic

* Expose the instrumentHTTP function as part of the module's public API

* Add integration tests covering the instrumentHTTP function

* Update cmd/tests/tracing_module_test.go

Co-authored-by: Ivan <[email protected]>

* Update cmd/tests/tracing_module_test.go

Co-authored-by: Ivan <[email protected]>

* Add unit tests for the instrumentHTTP function

* Remove unnecessary integration tests

* Add integration test illustrating instrumentHTTP in multi-file setup

* Move unit test setup in dedicated struct

* Adjust test ensuring instrumenting function from other module works

* Move tracing-client example in the correct samples folder

Co-authored-by: Ivan <[email protected]>
  • Loading branch information
oleiade and codebien authored Jan 26, 2023
1 parent 1d99b0b commit daa866a
Show file tree
Hide file tree
Showing 15 changed files with 1,415 additions and 0 deletions.
338 changes: 338 additions & 0 deletions cmd/tests/tracing_module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
package tests

import (
"bytes"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.k6.io/k6/cmd"
"go.k6.io/k6/lib/testutils/httpmultibin"
)

func TestTracingModuleClient(t *testing.T) {
t.Parallel()
tb := httpmultibin.NewHTTPMultiBin(t)

var gotRequests int64

tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)
assert.NotEmpty(t, r.Header.Get("traceparent"))
assert.Len(t, r.Header.Get("traceparent"), 55)
})

script := tb.Replacer.Replace(`
import http from "k6/http";
import { check } from "k6";
import tracing from "k6/experimental/tracing";
const instrumentedHTTP = new tracing.Client({
propagator: "w3c",
})
export default function () {
instrumentedHTTP.del("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.get("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.head("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.options("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.patch("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.post("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.put("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.request("GET", "HTTPBIN_IP_URL/tracing");
};
`)

ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0)
cmd.ExecuteWithGlobalState(ts.GlobalState)

assert.Equal(t, int64(8), atomic.LoadInt64(&gotRequests))

jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

assertHasTraceIDMetadata(t, jsonResults)
}

func TestTracingClient_DoesNotInterfereWithHTTPModule(t *testing.T) {
t.Parallel()
tb := httpmultibin.NewHTTPMultiBin(t)

var gotRequests int64
var gotInstrumentedRequests int64

tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)

if r.Header.Get("traceparent") != "" {
atomic.AddInt64(&gotInstrumentedRequests, 1)
assert.Len(t, r.Header.Get("traceparent"), 55)
}
})

script := tb.Replacer.Replace(`
import http from "k6/http";
import { check } from "k6";
import tracing from "k6/experimental/tracing";
const instrumentedHTTP = new tracing.Client({
propagator: "w3c",
})
export default function () {
instrumentedHTTP.get("HTTPBIN_IP_URL/tracing");
http.get("HTTPBIN_IP_URL/tracing");
instrumentedHTTP.head("HTTPBIN_IP_URL/tracing");
};
`)

ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0)
cmd.ExecuteWithGlobalState(ts.GlobalState)

assert.Equal(t, int64(3), atomic.LoadInt64(&gotRequests))
assert.Equal(t, int64(2), atomic.LoadInt64(&gotInstrumentedRequests))
}

func TestTracingInstrumentHTTP_W3C(t *testing.T) {
t.Parallel()
tb := httpmultibin.NewHTTPMultiBin(t)

var gotRequests int64

tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)
assert.NotEmpty(t, r.Header.Get("traceparent"))
assert.Len(t, r.Header.Get("traceparent"), 55)
})

script := tb.Replacer.Replace(`
import http from "k6/http";
import tracing from "k6/experimental/tracing";
tracing.instrumentHTTP({
propagator: "w3c",
})
export default function () {
http.del("HTTPBIN_IP_URL/tracing");
http.get("HTTPBIN_IP_URL/tracing");
http.head("HTTPBIN_IP_URL/tracing");
http.options("HTTPBIN_IP_URL/tracing");
http.patch("HTTPBIN_IP_URL/tracing");
http.post("HTTPBIN_IP_URL/tracing");
http.put("HTTPBIN_IP_URL/tracing");
http.request("GET", "HTTPBIN_IP_URL/tracing");
};
`)

ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0)
cmd.ExecuteWithGlobalState(ts.GlobalState)

assert.Equal(t, int64(8), atomic.LoadInt64(&gotRequests))

jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

assertHasTraceIDMetadata(t, jsonResults)
}

func TestTracingInstrumentHTTP_Jaeger(t *testing.T) {
t.Parallel()
tb := httpmultibin.NewHTTPMultiBin(t)

var gotRequests int64

tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)
assert.NotEmpty(t, r.Header.Get("uber-trace-id"))
assert.Len(t, r.Header.Get("uber-trace-id"), 45)
})

script := tb.Replacer.Replace(`
import http from "k6/http";
import { check } from "k6";
import tracing from "k6/experimental/tracing";
tracing.instrumentHTTP({
propagator: "jaeger",
})
export default function () {
http.del("HTTPBIN_IP_URL/tracing");
http.get("HTTPBIN_IP_URL/tracing");
http.head("HTTPBIN_IP_URL/tracing");
http.options("HTTPBIN_IP_URL/tracing");
http.patch("HTTPBIN_IP_URL/tracing");
http.post("HTTPBIN_IP_URL/tracing");
http.put("HTTPBIN_IP_URL/tracing");
http.request("GET", "HTTPBIN_IP_URL/tracing");
};
`)

ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0)
cmd.ExecuteWithGlobalState(ts.GlobalState)

assert.Equal(t, int64(8), atomic.LoadInt64(&gotRequests))

jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

assertHasTraceIDMetadata(t, jsonResults)
}

func TestTracingInstrumentHTTP_FillsParams(t *testing.T) {
t.Parallel()
tb := httpmultibin.NewHTTPMultiBin(t)

var gotRequests int64

tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)

assert.NotEmpty(t, r.Header.Get("traceparent"))
assert.Len(t, r.Header.Get("traceparent"), 55)

assert.NotEmpty(t, r.Header.Get("X-Test-Header"))
assert.Equal(t, "test", r.Header.Get("X-Test-Header"))
})

script := tb.Replacer.Replace(`
import http from "k6/http";
import tracing from "k6/experimental/tracing";
tracing.instrumentHTTP({
propagator: "w3c",
})
const testHeaders = {
"X-Test-Header": "test",
}
export default function () {
http.del("HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
http.get("HTTPBIN_IP_URL/tracing", { headers: testHeaders });
http.head("HTTPBIN_IP_URL/tracing", { headers: testHeaders });
http.options("HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
http.patch("HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
http.post("HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
http.put("HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
http.request("GET", "HTTPBIN_IP_URL/tracing", null, { headers: testHeaders });
};
`)

ts := getSingleFileTestState(t, script, []string{"--out", "json=results.json"}, 0)
cmd.ExecuteWithGlobalState(ts.GlobalState)

assert.Equal(t, int64(8), atomic.LoadInt64(&gotRequests))

jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

assertHasTraceIDMetadata(t, jsonResults)
}

func TestTracingInstrummentHTTP_SupportsMultipleTestScripts(t *testing.T) {
t.Parallel()

var gotRequests int64

tb := httpmultibin.NewHTTPMultiBin(t)
tb.Mux.HandleFunc("/tracing", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&gotRequests, 1)

assert.NotEmpty(t, r.Header.Get("traceparent"))
assert.Len(t, r.Header.Get("traceparent"), 55)
})

mainScript := tb.Replacer.Replace(`
import http from "k6/http";
import tracing from "k6/experimental/tracing";
import { iShouldBeInstrumented } from "./imported.js";
tracing.instrumentHTTP({
propagator: "w3c",
})
export default function() {
iShouldBeInstrumented();
};
`)

importedScript := tb.Replacer.Replace(`
import http from "k6/http";
export function iShouldBeInstrumented() {
http.head("HTTPBIN_IP_URL/tracing");
}
`)

ts := NewGlobalTestState(t)
require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "main.js"), []byte(mainScript), 0o644))
require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "imported.js"), []byte(importedScript), 0o644))

ts.CmdArgs = []string{"k6", "run", "--out", "json=results.json", "main.js"}
ts.ExpectedExitCode = 0

cmd.ExecuteWithGlobalState(ts.GlobalState)

jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

assert.Equal(t, int64(1), atomic.LoadInt64(&gotRequests))
assertHasTraceIDMetadata(t, jsonResults)
}

// assertHasTraceIDMetadata checks that the trace_id metadata is present and has the correct format
// for all http metrics in the json results file.
func assertHasTraceIDMetadata(t *testing.T, jsonResults []byte) {
gotHTTPDataPoints := false

for _, jsonLine := range bytes.Split(jsonResults, []byte("\n")) {
if len(jsonLine) == 0 {
continue
}

var line sampleEnvelope
require.NoError(t, json.Unmarshal(jsonLine, &line))

if line.Type != "Point" {
continue
}

// Filter metric samples which are not related to http
if !strings.HasPrefix(line.Metric, "http_") {
continue
}

gotHTTPDataPoints = true

anyTraceID, hasTraceID := line.Data.Metadata["trace_id"]
require.True(t, hasTraceID)

traceID, gotTraceID := anyTraceID.(string)
require.True(t, gotTraceID)

assert.Len(t, traceID, 32)
}

assert.True(t, gotHTTPDataPoints)
}

// sampleEnvelope is a trimmed version of the struct found
// in output/json/wrapper.go
// TODO: use the json output's wrapper struct instead if it's ever exported
type sampleEnvelope struct {
Metric string `json:"metric"`
Type string `json:"type"`
Data struct {
Value float64 `json:"value"`
Metadata map[string]interface{} `json:"metadata"`
} `json:"data"`
}
2 changes: 2 additions & 0 deletions js/jsmodules.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"go.k6.io/k6/js/modules/k6/data"
"go.k6.io/k6/js/modules/k6/encoding"
"go.k6.io/k6/js/modules/k6/execution"
"go.k6.io/k6/js/modules/k6/experimental/tracing"
"go.k6.io/k6/js/modules/k6/grpc"
"go.k6.io/k6/js/modules/k6/html"
"go.k6.io/k6/js/modules/k6/http"
Expand All @@ -30,6 +31,7 @@ func getInternalJSModules() map[string]interface{} {
"k6/experimental/redis": redis.New(),
"k6/experimental/websockets": &expws.RootModule{},
"k6/experimental/timers": timers.New(),
"k6/experimental/tracing": tracing.New(),
"k6/net/grpc": grpc.New(),
"k6/html": html.New(),
"k6/http": http.New(),
Expand Down
Loading

0 comments on commit daa866a

Please sign in to comment.