Skip to content

Commit

Permalink
WIP tracing
Browse files Browse the repository at this point in the history
  • Loading branch information
rhcarvalho committed Oct 16, 2020
1 parent 7958b9d commit 9c137c3
Show file tree
Hide file tree
Showing 6 changed files with 475 additions and 0 deletions.
4 changes: 4 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ type ClientOptions struct {
// all events are sent. Thus, as a historical special case, the sample rate
// 0.0 is treated as if it was 1.0.
SampleRate float64
// The sample rate for sampling traces in the range [0.0, 1.0].
TracesSampleRate float64
// Used to customize the sampling of traces, overrides TracesSampleRate.
TracesSampler TracesSampler
// List of regexp strings that will be used to match against event's message
// and if applicable, caught errors type and value.
// If the match is found, then a whole event will be dropped.
Expand Down
9 changes: 9 additions & 0 deletions hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ func GetHubFromContext(ctx context.Context) *Hub {
return nil
}

// HubFromContext returns either a hub stored in the context or the current hub.
// The return value is guaranteed to be non-nil, unlike GetHubFromContext.
func HubFromContext(ctx context.Context) *Hub {
if hub, ok := ctx.Value(HubContextKey).(*Hub); ok {
return hub
}
return currentHub
}

// SetHubOnContext stores given Hub instance on the Context struct and returns a new Context.
func SetHubOnContext(ctx context.Context, hub *Hub) context.Context {
return context.WithValue(ctx, HubContextKey, hub)
Expand Down
188 changes: 188 additions & 0 deletions span.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
package sentry

import (
"context"
"encoding/json"
"fmt"
"math/rand"
"strings"
"time"
)

// A Span is the building block of a Sentry transaction. Spans build up a tree
// structure of timed operations. The span tree makes up a transaction event
// that is sent to Sentry when the root span is finished.
type Span interface {
// Context returns the context containing the current span.
Context() context.Context

// SpanContext returns the data for the current span.
SpanContext() SpanContext // or SpanContext for immutability, returning a copy
// Set...(...) // to update SpanContext

// Finish sets the current span's end time. If the current span is the root
// of a span tree, Finish sends the span tree to Sentry as a transaction.
Finish()

// StartChild starts a new child span from the current span.
//
// The call current.StartChild(op, opts) is a shortcut equivalent to
// StartSpan(current.Context(), op, opts).
StartChild(operation string, options ...interface{ todo() }) Span

// spanRecorder stores the span tree. It should never return nil.
spanRecorder() *spanRecorder

// A non-exported method prevents users from implementing the interface,
// allowing it to grow later without breaking compatibility.
}

// spanContextKey is used to store span values in contexts.
type spanContextKey struct{}

// A RawSpan represents a span as it is sent over the network.
//
// Experimental: This is part of a beta feature of the SDK.
Expand Down Expand Up @@ -32,3 +68,155 @@ type TraceContext struct {
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
}

// TraceID identifies a trace.
type TraceID [16]byte

// SpanID identifies a span.
type SpanID [8]byte

// SpanContext holds the information about a span necessary for trace
// propagation.
type SpanContext struct {
TraceID TraceID
SpanID SpanID
Sampled bool
}

// ToTraceparent returns the trace propagation value used with the sentry-trace
// HTTP header.
func (c SpanContext) ToTraceparent() string {
var b strings.Builder
fmt.Fprintf(&b, "%x-%x-", c.TraceID, c.SpanID)
if c.Sampled {
b.WriteByte('1')
} else {
b.WriteByte('0')
}
return b.String()
}

// SpanFromContext returns the last span stored in the context or ........
//
// TODO: ensure this is really needed as public API ---
// SpanFromContext(ctx).StartChild(...) === StartSpan(ctx, ...)
// Do we need this for anything else?
// If we remove this we can also remove noopSpan.
func SpanFromContext(ctx context.Context) Span {
if span, ok := ctx.Value(spanContextKey{}).(Span); ok {
return span
}
return noopSpan{ctx: ctx}
}

// StartSpan starts a new span to describe an operation. The new span will be a
// child of the last span stored in ctx, if any.
func StartSpan(ctx context.Context, operation string, options ...interface{ todo() }) Span {
var span normalSpan
span.ctx = context.WithValue(ctx, spanContextKey{}, &span)

parent, hasParent := ctx.Value(spanContextKey{}).(Span)
if hasParent {
span.parent = parent
span.spanContext.TraceID = parent.SpanContext().TraceID
span.recorder = parent.spanRecorder()
if span.recorder == nil {
panic("is nil")
}
} else {
_, err := rand.Read(span.spanContext.TraceID[:]) // TODO: custom RNG
if err != nil {
panic(err)
}
span.recorder = &spanRecorder{}
}
span.recorder.spans = append(span.recorder.spans, &span)
_, err := rand.Read(span.spanContext.SpanID[:]) // TODO: custom RNG
if err != nil {
panic(err)
}

// TODO: apply options

// TODO: an option should be able to override the sampler, such that one can
// disable sampling or force sampled:true for one specific transaction at
// start time.
hasSamplingDecision := false // Sampling Decision #1 (see https://develop.sentry.dev/sdk/unified-api/tracing/#sampling)

// TODO: set StartTime

if !hasSamplingDecision {
hub := HubFromContext(ctx)
var clientOptions ClientOptions
client := hub.Client()
if client != nil {
clientOptions = hub.Client().Options() // TODO: check nil client
}
sampler := clientOptions.TracesSampler
samplingContext := SamplingContext{Span: &span, Parent: parent}
if sampler != nil {
span.spanContext.Sampled = sampler.Sample(samplingContext) // Sampling Decision #2
} else {
if parent != nil {
span.spanContext.Sampled = parent.SpanContext().Sampled // Sampling Decision #3
} else {
sampler = &fixedRateSampler{ // TODO: pre-compute the TracesSampler once and avoid extra computations in StartSpan.
Rand: rand.New(rand.NewSource(1)), // TODO: use proper RNG
Rate: clientOptions.TracesSampleRate,
}
span.spanContext.Sampled = sampler.Sample(samplingContext) // Sampling Decision #4
}
}
}

return &span
}

// WithTransactionName sets the transaction name of a span. Only the name of the
// root span in a span tree is used to name the transaction encompassing the
// tree.
func WithTransactionName(name string) interface{ todo() } {
// TODO: to be implemented
return nil
}

type noopSpan struct {
ctx context.Context
}

var _ Span = noopSpan{}

func (s noopSpan) Context() context.Context { return s.ctx }
func (s noopSpan) SpanContext() SpanContext { return SpanContext{} }
func (s noopSpan) Finish() {}
func (s noopSpan) StartChild(operation string, options ...interface{ todo() }) Span {
return StartSpan(s.ctx, operation, options...)
}
func (s noopSpan) spanRecorder() *spanRecorder { return &spanRecorder{} }

type normalSpan struct { // move to an internal package, rename to a cleaner name like Span
ctx context.Context
spanContext SpanContext

parent Span
recorder *spanRecorder
}

var _ Span = &normalSpan{}

func (s *normalSpan) Context() context.Context { return s.ctx }
func (s *normalSpan) SpanContext() SpanContext { return s.spanContext }
func (s *normalSpan) Finish() {}
func (s *normalSpan) StartChild(operation string, options ...interface{ todo() }) Span {
return StartSpan(s.Context(), operation, options...)
}
func (s *normalSpan) spanRecorder() *spanRecorder { return s.recorder }

func (s *normalSpan) MarshalJSON() ([]byte, error) {
return json.Marshal("hello")
}

// A spanRecorder stores a span tree that makes up a transaction.
type spanRecorder struct {
spans []Span
}
141 changes: 141 additions & 0 deletions span_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package sentry

import (
"context"
"testing"
)

func TestStartSpan(t *testing.T) {
ctx := NewTestContext(ClientOptions{})
span := StartSpan(ctx, "top", WithTransactionName("Test Transaction"))
span.Finish()

SpanCheck{
RecorderLen: 1,
}.Check(t, span)

// TODO calling Finish sets the span EndTime (and every span
// has StartTime set?)
}

func TestStartChild(t *testing.T) {
ctx := NewTestContext(ClientOptions{})
span := StartSpan(ctx, "top", WithTransactionName("Test Transaction"))
child := span.StartChild("child")
child.Finish()
span.Finish()

c := SpanCheck{
RecorderLen: 2,
}
c.Check(t, span)
c.Check(t, child)
}

func TestSpanFromContext(t *testing.T) {
// SpanFromContext always returns a non-nil value, such that you can use
// it without nil checks.
// When no span was in the context, the returned value is a no-op.
// Calling StartChild on the no-op creates a valid transaction.
// SpanFromContext(ctx).StartChild(...) === StartSpan(ctx, ...)

ctx := NewTestContext(ClientOptions{})
span := SpanFromContext(ctx)

SpanCheck{
ZeroTraceID: true,
ZeroSpanID: true,
}.Check(t, span)

// Should create a transaction
child := span.StartChild("top")
SpanCheck{
RecorderLen: 1,
}.Check(t, child)

// TODO: check behavior of Finishing sending transactions to Sentry
}

// testSecretContextKey is used to store a "secret value" in a context so that
// we can check that operations on that context preserves our original context.
type testSecretContextKey struct{}
type testSecretContextValue struct{}

func NewTestContext(options ClientOptions) context.Context {
client, err := NewClient(options)
if err != nil {
panic(err)
}
hub := NewHub(client, NewScope())
ctx := context.WithValue(context.Background(), testSecretContextKey{}, testSecretContextValue{})
return SetHubOnContext(ctx, hub)
}

// Zero values of TraceID and SpanID used for comparisons.
var (
zeroTraceID TraceID
zeroSpanID SpanID
)

// A SpanCheck is a test helper describing span properties that can be checked
// with the Check method.
type SpanCheck struct {
Sampled bool
ZeroTraceID bool
ZeroSpanID bool
RecorderLen int
}

func (c SpanCheck) Check(t *testing.T, span Span) {
t.Helper()

// Invariant: context preservation
gotCtx := span.Context()
if _, ok := gotCtx.Value(testSecretContextKey{}).(testSecretContextValue); !ok {
t.Errorf("original context lost")
}
// Invariant: SpanFromContext(span.Context) == span
if SpanFromContext(gotCtx) != span {
t.Errorf("span not in its context")
}

spanContext := span.SpanContext()
if got := spanContext.TraceID == zeroTraceID; got != c.ZeroTraceID {
want := "zero"
if !c.ZeroTraceID {
want = "non-" + want
}
t.Errorf("got TraceID = %x, want %s", spanContext.TraceID, want)
}
if got := spanContext.SpanID == zeroSpanID; got != c.ZeroSpanID {
want := "zero"
if !c.ZeroSpanID {
want = "non-" + want
}
t.Errorf("got SpanID = %x, want %s", spanContext.SpanID, want)
}
if got, want := spanContext.Sampled, c.Sampled; got != want {
t.Errorf("got Sampled = %v, want %v", got, want)
}

if got, want := len(span.spanRecorder().spans), c.RecorderLen; got != want {
t.Errorf("got %d spans in recorder, want %d", got, want)
}
}

func TestToTraceparent(t *testing.T) {
tests := []struct {
ctx SpanContext
want string
}{
{SpanContext{}, "00000000000000000000000000000000-0000000000000000-0"},
{SpanContext{Sampled: true}, "00000000000000000000000000000000-0000000000000000-1"},
{SpanContext{TraceID: TraceID{1}}, "01000000000000000000000000000000-0000000000000000-0"},
{SpanContext{SpanID: SpanID{1}}, "00000000000000000000000000000000-0100000000000000-0"},
}
for _, tt := range tests {
if got := tt.ctx.ToTraceparent(); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
}
}

0 comments on commit 9c137c3

Please sign in to comment.