Skip to content

Commit

Permalink
Merge pull request #2855 from grafana/experimental/tracing-module-ins…
Browse files Browse the repository at this point in the history
…trumentHTTP

Experimental tracing module (3/3): define and expose a `instrumentHTTP` function
  • Loading branch information
oleiade authored Jan 26, 2023
2 parents c2648ac + 7187e12 commit f6ba5e4
Show file tree
Hide file tree
Showing 4 changed files with 399 additions and 32 deletions.
259 changes: 228 additions & 31 deletions cmd/tests/tracing_module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -56,37 +57,7 @@ func TestTracingModuleClient(t *testing.T) {
jsonResults, err := afero.ReadFile(ts.FS, "results.json")
require.NoError(t, err)

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)
assertHasTraceIDMetadata(t, jsonResults)
}

func TestTracingClient_DoesNotInterfereWithHTTPModule(t *testing.T) {
Expand Down Expand Up @@ -128,6 +99,232 @@ func TestTracingClient_DoesNotInterfereWithHTTPModule(t *testing.T) {
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
Expand Down
70 changes: 69 additions & 1 deletion js/modules/k6/experimental/tracing/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ type (
// ModuleInstance represents an instance of the JS module.
ModuleInstance struct {
vu modules.VU

// Client holds the module's default tracing client.
*Client
}
)

Expand Down Expand Up @@ -45,7 +48,8 @@ func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance {
func (mi *ModuleInstance) Exports() modules.Exports {
return modules.Exports{
Named: map[string]interface{}{
"Client": mi.newClient,
"Client": mi.newClient,
"instrumentHTTP": mi.instrumentHTTP,
},
}
}
Expand All @@ -69,3 +73,67 @@ func (mi *ModuleInstance) newClient(cc goja.ConstructorCall) *goja.Object {

return rt.ToValue(NewClient(mi.vu, opts)).ToObject(rt)
}

// InstrumentHTTP instruments the HTTP module with tracing headers.
//
// When used in the context of a k6 script, it will automatically replace
// the imported http module's methods with instrumented ones.
func (mi *ModuleInstance) instrumentHTTP(options options) {
rt := mi.vu.Runtime()

if mi.vu.State() != nil {
common.Throw(rt, common.NewInitContextError("tracing module's instrumentHTTP can only be called in the init context"))
}

if mi.Client != nil {
err := errors.New(
"tracing module's instrumentHTTP can only be called once. " +
"if you were attempting to reconfigure the instrumentation, " +
"please consider using the tracing.Client instead",
)
common.Throw(rt, err)
}

// Initialize the tracing module's instance default client,
// and configure it using the user-supplied set of options.
mi.Client = NewClient(mi.vu, options)

// Explicitly inject the http module in the VU's runtime.
// This allows us to later on override the http module's methods
// with instrumented ones.
httpModuleValue, err := rt.RunString(`require('k6/http')`)
if err != nil {
common.Throw(rt, err)
}

httpModuleObj := httpModuleValue.ToObject(rt)

// Closure overriding a method of the provided imported module object.
//
// The `onModule` argument should be a *goja.Object obtained by requiring
// or importing the 'k6/http' module and converting it to an object.
//
// The `value` argument is expected to be callable.
mustSetHTTPMethod := func(method string, onModule *goja.Object, value interface{}) {
// Inject the new get function, adding tracing headers
// to the request in the HTTP module object.
err = onModule.Set(method, value)
if err != nil {
common.Throw(
rt,
fmt.Errorf("unable to overwrite http.%s method with instrumented one; reason: %w", method, err),
)
}
}

// Overwrite the implementation of the http module's method with the instrumented
// ones exposed by the `tracing.Client` struct.
mustSetHTTPMethod("del", httpModuleObj, mi.Client.Del)
mustSetHTTPMethod("get", httpModuleObj, mi.Client.Get)
mustSetHTTPMethod("head", httpModuleObj, mi.Client.Head)
mustSetHTTPMethod("options", httpModuleObj, mi.Client.Options)
mustSetHTTPMethod("patch", httpModuleObj, mi.Client.Patch)
mustSetHTTPMethod("post", httpModuleObj, mi.Client.Patch)
mustSetHTTPMethod("put", httpModuleObj, mi.Client.Patch)
mustSetHTTPMethod("request", httpModuleObj, mi.Client.Request)
}
Loading

0 comments on commit f6ba5e4

Please sign in to comment.