From 0c190bc0232008370c3f42fc6236324f8d2b535d Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Tue, 11 Jul 2023 15:34:14 -0300 Subject: [PATCH] feat: support span events (#2911) * feat: add span events as an attribute called "events" (#2886) * feat: add span events as an attribute called "events" * update attribute name to "span.events" * fix test * feat: support events in Jaeger, Tempo, and all OTLP datastores (both gRPC and HTTP variants) (#2887) support events in jaeger and tempo (grpc and http) * feat: events app insights (#2905) * feat: support span events in app insights * don't ignore error and fix function names * docs: span events (#2920) * add docs and test in expression executor * Update docs/docs/cli/creating-test-specifications.md Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- docs/docs/cli/creating-test-specifications.md | 16 ++ server/expression/executor_test.go | 5 + server/model/spans.go | 16 ++ server/model/traces.go | 2 + server/model/traces_test.go | 130 +++++---- server/tracedb/azureappinsights.go | 246 +++++++++++------- server/traces/otel_converter.go | 24 ++ server/traces/otel_http_converter.go | 32 +++ 8 files changed, 333 insertions(+), 138 deletions(-) diff --git a/docs/docs/cli/creating-test-specifications.md b/docs/docs/cli/creating-test-specifications.md index 25c8269bd3..39806ffa3f 100644 --- a/docs/docs/cli/creating-test-specifications.md +++ b/docs/docs/cli/creating-test-specifications.md @@ -86,3 +86,19 @@ For more information about selectors or assertions, take a look at the documenta | `>=` | Check if value from left side is larger or equal to the one on the right side of the operation. | | `contains` | Check if value on the right side of the operation is contained inside of the value of the left side of the operation. | | `not-contains` | Check if value on the right side of the operation is not contained inside of the value of the left side of the operation. | + +## Testing Span Events + +As an MVP of how to test span events, we are injecting `all spans` events into the span attributes as a JSON array. To assert your span events, use the `json_path` filter to select and test the **write events**. + +```yaml +specs: + - selector: span[name = "my span"] + assertions: + - attr:span.events | json_path '$[?(@.name = "event name")].attributes.key' = "expected_value" +``` + +### Query breakdown + +* **@.name = "event name"**: select the event with name "**event name**" +* $[?(@.name = "event name")]**.attributes.key**: select the attribute "**key**" from the event with name "**event name**" diff --git a/server/expression/executor_test.go b/server/expression/executor_test.go index 00b2de52d3..66c5ac27ad 100644 --- a/server/expression/executor_test.go +++ b/server/expression/executor_test.go @@ -291,6 +291,11 @@ func TestArrayExecution(t *testing.T) { Query: `'{ "array": [{ "name": "john", "age": 37 }, { "name": "jonas", "age": 38 }]}' | json_path '$.array[*].age' = [37, 38]`, ShouldPass: true, }, + { + Name: "arrays_can_be_filtered_by_value", + Query: `'[{ "name": "john", "age": 37 }, { "name": "jonas", "age": 38 }]' | json_path '$[?(@.name == "john")].age' = 37`, + ShouldPass: true, + }, { Name: "should_check_if_array_contains_value", Query: `[31,35,39] contains 35`, diff --git a/server/model/spans.go b/server/model/spans.go index 7d117ba8b9..27194640a9 100644 --- a/server/model/spans.go +++ b/server/model/spans.go @@ -73,11 +73,27 @@ type Span struct { EndTime time.Time Attributes Attributes Kind SpanKind + Events []SpanEvent Parent *Span `json:"-"` Children []*Span `json:"-"` } +func (s *Span) injectEventsIntoAttributes() { + if s.Events == nil { + s.Events = make([]SpanEvent, 0) + } + + eventsJson, _ := json.Marshal(s.Events) + s.Attributes["span.events"] = string(eventsJson) +} + +type SpanEvent struct { + Name string `json:"name"` + Timestamp time.Time `json:"timestamp"` + Attributes Attributes `json:"attributes"` +} + type encodedSpan struct { ID string Name string diff --git a/server/model/traces.go b/server/model/traces.go index e0572da5b2..47b0d349d9 100644 --- a/server/model/traces.go +++ b/server/model/traces.go @@ -28,6 +28,8 @@ func NewTrace(traceID string, spans []Span) Trace { rootSpans := make([]*Span, 0) for _, span := range spanMap { + span.injectEventsIntoAttributes() + parentID := span.Attributes[TracetestMetadataFieldParentID] parentSpan, found := spanMap[parentID] if !found { diff --git a/server/model/traces_test.go b/server/model/traces_test.go index 2156b71725..40bdb05e72 100644 --- a/server/model/traces_test.go +++ b/server/model/traces_test.go @@ -1,6 +1,7 @@ package model_test import ( + "encoding/json" "testing" "time" @@ -13,10 +14,10 @@ import ( ) func TestTraces(t *testing.T) { - rootSpan := newSpan("Root", nil) - childSpan1 := newSpan("child 1", &rootSpan) - childSpan2 := newSpan("child 2", &rootSpan) - grandchildSpan := newSpan("grandchild", &childSpan2) + rootSpan := newSpan("Root") + childSpan1 := newSpan("child 1", withParent(&rootSpan)) + childSpan2 := newSpan("child 2", withParent(&rootSpan)) + grandchildSpan := newSpan("grandchild", withParent(&childSpan2)) spans := []model.Span{rootSpan, childSpan1, childSpan2, grandchildSpan} trace := model.NewTrace("trace", spans) @@ -29,12 +30,12 @@ func TestTraces(t *testing.T) { } func TestTraceWithMultipleRoots(t *testing.T) { - root1 := newSpan("Root 1", nil) - root1Child := newSpan("Child from root 1", &root1) - root2 := newSpan("Root 2", nil) - root2Child := newSpan("Child from root 2", &root2) - root3 := newSpan("Root 3", nil) - root3Child := newSpan("Child from root 3", &root3) + root1 := newSpan("Root 1") + root1Child := newSpan("Child from root 1", withParent(&root1)) + root2 := newSpan("Root 2") + root2Child := newSpan("Child from root 2", withParent(&root2)) + root3 := newSpan("Root 3") + root3Child := newSpan("Child from root 3", withParent(&root3)) spans := []model.Span{root1, root1Child, root2, root2Child, root3, root3Child} trace := model.NewTrace("trace", spans) @@ -84,15 +85,15 @@ func TestTraceWithMultipleRootsFromOtel(t *testing.T) { } func TestInjectingNewRootWhenSingleRoot(t *testing.T) { - rootSpan := newSpan("Root", nil) - childSpan1 := newSpan("child 1", &rootSpan) - childSpan2 := newSpan("child 2", &rootSpan) - grandchildSpan := newSpan("grandchild", &childSpan2) + rootSpan := newSpan("Root") + childSpan1 := newSpan("child 1", withParent(&rootSpan)) + childSpan2 := newSpan("child 2", withParent(&rootSpan)) + grandchildSpan := newSpan("grandchild", withParent(&childSpan2)) spans := []model.Span{rootSpan, childSpan1, childSpan2, grandchildSpan} trace := model.NewTrace("trace", spans) - newRoot := newSpan("new Root", nil) + newRoot := newSpan("new Root") newTrace := trace.InsertRootSpan(newRoot) assert.Len(t, newTrace.Flat, 5) @@ -102,12 +103,12 @@ func TestInjectingNewRootWhenSingleRoot(t *testing.T) { } func TestInjectingNewRootWhenMultipleRoots(t *testing.T) { - root1 := newSpan("Root 1", nil) - root1Child := newSpan("Child from root 1", &root1) - root2 := newSpan("Root 2", nil) - root2Child := newSpan("Child from root 2", &root2) - root3 := newSpan("Root 3", nil) - root3Child := newSpan("Child from root 3", &root3) + root1 := newSpan("Root 1") + root1Child := newSpan("Child from root 1", withParent(&root1)) + root2 := newSpan("Root 2") + root2Child := newSpan("Child from root 2", withParent(&root2)) + root3 := newSpan("Root 3") + root3Child := newSpan("Child from root 3", withParent(&root3)) spans := []model.Span{root1, root1Child, root2, root2Child, root3, root3Child} trace := model.NewTrace("trace", spans) @@ -116,7 +117,7 @@ func TestInjectingNewRootWhenMultipleRoots(t *testing.T) { require.NotNil(t, oldRoot.Parent) } - newRoot := newSpan("new Root", nil) + newRoot := newSpan("new Root") newTrace := trace.InsertRootSpan(newRoot) assert.Len(t, newTrace.Flat, 7) @@ -133,12 +134,12 @@ func TestInjectingNewRootWhenMultipleRoots(t *testing.T) { } func TestNoTemporaryRootIfTracetestRootExists(t *testing.T) { - root1 := newSpan("Root 1", nil) - root1Child := newSpan("Child from root 1", &root1) - root2 := newSpan(model.TriggerSpanName, nil) - root2Child := newSpan("Child from root 2", &root2) - root3 := newSpan("Root 3", nil) - root3Child := newSpan("Child from root 3", &root3) + root1 := newSpan("Root 1") + root1Child := newSpan("Child from root 1", withParent(&root1)) + root2 := newSpan(model.TriggerSpanName) + root2Child := newSpan("Child from root 2", withParent(&root2)) + root3 := newSpan("Root 3") + root3Child := newSpan("Child from root 3", withParent(&root3)) spans := []model.Span{root1, root1Child, root2, root2Child, root3, root3Child} trace := model.NewTrace("trace", spans) @@ -148,12 +149,12 @@ func TestNoTemporaryRootIfTracetestRootExists(t *testing.T) { } func TestNoTemporaryRootIfATemporaryRootExists(t *testing.T) { - root1 := newSpan("Root 1", nil) - root1Child := newSpan("Child from root 1", &root1) - root2 := newSpan(model.TemporaryRootSpanName, nil) - root2Child := newSpan("Child from root 2", &root2) - root3 := newSpan("Root 3", nil) - root3Child := newSpan("Child from root 3", &root3) + root1 := newSpan("Root 1") + root1Child := newSpan("Child from root 1", withParent(&root1)) + root2 := newSpan(model.TemporaryRootSpanName) + root2Child := newSpan("Child from root 2", withParent(&root2)) + root3 := newSpan("Root 3") + root3Child := newSpan("Child from root 3", withParent(&root3)) spans := []model.Span{root1, root1Child, root2, root2Child, root3, root3Child} trace := model.NewTrace("trace", spans) @@ -163,12 +164,12 @@ func TestNoTemporaryRootIfATemporaryRootExists(t *testing.T) { } func TestTriggerSpanShouldBeRootWhenTemporaryRootExistsToo(t *testing.T) { - root1 := newSpan(model.TriggerSpanName, nil) - root1Child := newSpan("Child from root 1", &root1) - root2 := newSpan(model.TemporaryRootSpanName, nil) - root2Child := newSpan("Child from root 2", &root2) - root3 := newSpan("Root 3", nil) - root3Child := newSpan("Child from root 3", &root3) + root1 := newSpan(model.TriggerSpanName) + root1Child := newSpan("Child from root 1", withParent(&root1)) + root2 := newSpan(model.TemporaryRootSpanName) + root2Child := newSpan("Child from root 2", withParent(&root2)) + root3 := newSpan("Root 3") + root3Child := newSpan("Child from root 3", withParent(&root3)) spans := []model.Span{root1, root1Child, root2, root2Child, root3, root3Child} trace := model.NewTrace("trace", spans) @@ -177,18 +178,59 @@ func TestTriggerSpanShouldBeRootWhenTemporaryRootExistsToo(t *testing.T) { assert.Equal(t, root1.Name, trace.RootSpan.Name) } -func newSpan(name string, parent *model.Span) model.Span { +func TestEventsAreInjectedIntoAttributes(t *testing.T) { + rootSpan := newSpan("Root", withEvents([]model.SpanEvent{ + {Name: "event 1", Attributes: model.Attributes{"attribute1": "value"}}, + {Name: "event 2", Attributes: model.Attributes{"attribute2": "value"}}, + })) + childSpan1 := newSpan("child 1", withParent(&rootSpan)) + childSpan2 := newSpan("child 2", withParent(&rootSpan)) + grandchildSpan := newSpan("grandchild", withParent(&childSpan2)) + + spans := []model.Span{rootSpan, childSpan1, childSpan2, grandchildSpan} + trace := model.NewTrace("trace", spans) + + require.NotEmpty(t, trace.RootSpan.Attributes["span.events"]) + + events := []model.SpanEvent{} + err := json.Unmarshal([]byte(trace.RootSpan.Attributes["span.events"]), &events) + require.NoError(t, err) + + assert.Equal(t, "event 1", events[0].Name) + assert.Equal(t, "value", events[0].Attributes["attribute1"]) + assert.Equal(t, "event 2", events[1].Name) + assert.Equal(t, "value", events[1].Attributes["attribute2"]) +} + +type option func(*model.Span) + +func withParent(parent *model.Span) option { + return func(s *model.Span) { + s.Parent = parent + } +} + +func withEvents(events []model.SpanEvent) option { + return func(s *model.Span) { + s.Events = events + } +} + +func newSpan(name string, options ...option) model.Span { span := model.Span{ ID: id.NewRandGenerator().SpanID(), Name: name, - Parent: parent, Attributes: make(model.Attributes), StartTime: time.Now(), EndTime: time.Now().Add(1 * time.Second), } - if parent != nil { - span.Attributes[model.TracetestMetadataFieldParentID] = parent.ID.String() + for _, option := range options { + option(&span) + } + + if span.Parent != nil { + span.Attributes[model.TracetestMetadataFieldParentID] = span.Parent.ID.String() } return span diff --git a/server/tracedb/azureappinsights.go b/server/tracedb/azureappinsights.go index 8a7b4e230d..7144075e63 100644 --- a/server/tracedb/azureappinsights.go +++ b/server/tracedb/azureappinsights.go @@ -115,70 +115,180 @@ func (db *azureAppInsightsDB) GetTraceByID(ctx context.Context, traceID string) return parseAzureAppInsightsTrace(traceID, table) } -type columnIndex map[string]int +type spanTable struct { + rows []spanRow +} + +func (st *spanTable) Spans() []spanRow { + output := make([]spanRow, 0) + for _, row := range st.rows { + if row.Type() != "trace" { + output = append(output, row) + } + } + + return output +} + +func (st *spanTable) Events() []spanRow { + output := make([]spanRow, 0) + for _, row := range st.rows { + if row.Type() == "trace" { + output = append(output, row) + } + } + + return output +} + +type spanRow struct { + values map[string]any +} + +func (sr *spanRow) Get(name string) any { + return sr.values[name] +} + +func (sr *spanRow) Type() string { + return sr.values["itemType"].(string) +} + +func (sr *spanRow) ParentID() string { + return sr.values["operation_ParentId"].(string) +} + +func (sr *spanRow) SpanID() string { + return sr.values["id"].(string) +} + +func newSpanTable(table *azquery.Table) spanTable { + spanRows := make([]spanRow, 0, len(table.Rows)) + for _, row := range table.Rows { + spanRows = append(spanRows, newSpanRow(row, table.Columns)) + } + + return spanTable{spanRows} +} + +func newSpanRow(row azquery.Row, columns []*azquery.Column) spanRow { + values := make(map[string]any) + for i, column := range columns { + name := *column.Name + if value := row[i]; value != nil { + values[name] = value + } + } + + return spanRow{values} +} func parseAzureAppInsightsTrace(traceID string, table *azquery.Table) (model.Trace, error) { - columnIndex := mapColumnNames(table.Columns) - spans := make([]model.Span, len(table.Rows)) + spans, err := parseSpans(table) + if err != nil { + return model.Trace{}, err + } + + return model.NewTrace(traceID, spans), nil +} - for i, row := range table.Rows { - span, err := parseRowToSpan(row, columnIndex) +func parseSpans(table *azquery.Table) ([]model.Span, error) { + spanTable := newSpanTable(table) + spanRows := spanTable.Spans() + eventRows := spanTable.Events() + + spanEventsMap := make(map[string][]spanRow) + for _, eventRow := range eventRows { + spanEventsMap[eventRow.ParentID()] = append(spanEventsMap[eventRow.ParentID()], eventRow) + } + + spanMap := make(map[string]*model.Span) + for _, spanRow := range spanRows { + span, err := parseRowToSpan(spanRow) if err != nil { - return model.Trace{}, err + return []model.Span{}, err } - spans[i] = span + spanMap[span.ID.String()] = &span } - return model.NewTrace(traceID, spans), nil + for _, eventRow := range eventRows { + parentSpan := spanMap[eventRow.ParentID()] + event, err := parseEvent(eventRow) + if err != nil { + return []model.Span{}, err + } + + parentSpan.Events = append(parentSpan.Events, event) + } + + spans := make([]model.Span, 0, len(spanMap)) + for _, span := range spanMap { + spans = append(spans, *span) + } + + return spans, nil } -var columnNamesMap = map[string]string{ - "traceId": "operation_Id", - "spanId": "id", - "parentId": "operation_ParentId", - "name": "name", - "attributes": "customDimensions", - "startTime": "timestamp", - "duration": "duration", +func parseEvent(row spanRow) (model.SpanEvent, error) { + event := model.SpanEvent{ + Name: row.Get("message").(string), + } + + timestamp, err := time.Parse(time.RFC3339Nano, row.Get("timestamp").(string)) + if err != nil { + return event, fmt.Errorf("could not parse event timestamp: %w", err) + } + + event.Timestamp = timestamp + + attributes := make(model.Attributes, 0) + rawAttributes := row.Get("customDimensions").(string) + err = json.Unmarshal([]byte(rawAttributes), &attributes) + if err != nil { + return event, fmt.Errorf("could not unmarshal event attributes: %w", err) + } + + event.Attributes = attributes + + return event, nil } -func parseRowToSpan(row azquery.Row, columnIndex columnIndex) (model.Span, error) { +func parseRowToSpan(row spanRow) (model.Span, error) { attributes := make(model.Attributes, 0) span := model.Span{ Attributes: attributes, } var duration time.Duration - for name, index := range columnIndex { + for name, value := range row.values { switch name { - case "spanId": - err := parseSpanId(&span, row, index) + case "id": + err := parseSpanID(&span, value) if err != nil { return span, err } - case "attributes": - err := parseAttributes(&span, row, index) + case "customDimensions": + err := parseAttributes(&span, value) if err != nil { return span, err } - case "parentId": - err := parseParentId(&span, row, index) + case "operation_ParentId": + err := parseParentID(&span, value) if err != nil { return span, err } case "name": - err := parseName(&span, row, index) + err := parseName(&span, value) if err != nil { return span, err } - case "startTime": - err := parseStartTime(&span, row, index) + case "timestamp": + err := parseStartTime(&span, value) if err != nil { return span, err } case "duration": - timeDuration, err := parseDuration(row, index) + timeDuration, err := parseDuration(value) if err != nil { return span, err } @@ -191,28 +301,19 @@ func parseRowToSpan(row azquery.Row, columnIndex columnIndex) (model.Span, error return span, nil } -func parseSpanId(span *model.Span, row azquery.Row, index int) error { - if index == -1 { - return fmt.Errorf("spanId column not found") - } - - rawSpanId := row[index].(string) - spanId, err := trace.SpanIDFromHex(rawSpanId) +func parseSpanID(span *model.Span, value any) error { + spanID, err := trace.SpanIDFromHex(value.(string)) if err != nil { return fmt.Errorf("failed to parse spanId: %w", err) } - span.ID = spanId + span.ID = spanID return nil } -func parseAttributes(span *model.Span, row azquery.Row, index int) error { - if index == -1 { - return fmt.Errorf("attributes column not found") - } - +func parseAttributes(span *model.Span, value any) error { attributes := make(model.Attributes, 0) - rawAttributes := row[index].(string) + rawAttributes := value.(string) err := json.Unmarshal([]byte(rawAttributes), &attributes) if err != nil { return fmt.Errorf("failed to parse attributes: %w", err) @@ -224,26 +325,18 @@ func parseAttributes(span *model.Span, row azquery.Row, index int) error { return nil } -func parseParentId(span *model.Span, row azquery.Row, index int) error { - if index == -1 { - return fmt.Errorf("parentId column not found") - } - - rawParentId, ok := row[index].(string) +func parseParentID(span *model.Span, value any) error { + rawParentID, ok := value.(string) if ok { - span.Attributes[model.TracetestMetadataFieldParentID] = rawParentId + span.Attributes[model.TracetestMetadataFieldParentID] = rawParentID } else { span.Attributes[model.TracetestMetadataFieldParentID] = "" } return nil } -func parseName(span *model.Span, row azquery.Row, index int) error { - if index == -1 { - return fmt.Errorf("name column not found") - } - - rawName, ok := row[index].(string) +func parseName(span *model.Span, value any) error { + rawName, ok := value.(string) if ok { span.Name = rawName } else { @@ -252,12 +345,8 @@ func parseName(span *model.Span, row azquery.Row, index int) error { return nil } -func parseStartTime(span *model.Span, row azquery.Row, index int) error { - if index == -1 { - return fmt.Errorf("startTime column not found") - } - - rawStartTime := row[index].(string) +func parseStartTime(span *model.Span, value any) error { + rawStartTime := value.(string) startTime, err := time.Parse(time.RFC3339Nano, rawStartTime) if err != nil { return fmt.Errorf("failed to parse startTime: %w", err) @@ -267,45 +356,14 @@ func parseStartTime(span *model.Span, row azquery.Row, index int) error { return nil } -func parseDuration(row azquery.Row, index int) (time.Duration, error) { - if index == -1 { - return time.Duration(0), fmt.Errorf("duration column not found") - } - - rawDuration, ok := row[index].(float64) +func parseDuration(value any) (time.Duration, error) { + rawDuration, ok := value.(float64) if !ok { return time.Duration(0), fmt.Errorf("failed to parse duration") } return time.Duration(rawDuration), nil } -func mapColumnNames(columns []*azquery.Column) columnIndex { - columnIndex := columnIndex{ - "traceId": -1, - "parentId": -1, - "name": -1, - "attributes": -1, - "startTime": -1, - "duration": -1, - } - - for name, azureName := range columnNamesMap { - columnIndex[name] = findColumnByName(columns, azureName) - } - - return columnIndex -} - -func findColumnByName(columns []*azquery.Column, name string) int { - for i, column := range columns { - if *column.Name == name { - return i - } - } - - return -1 -} - type tokenCredentials struct { accessToken string } diff --git a/server/traces/otel_converter.go b/server/traces/otel_converter.go index 80031fd08a..e0d7332d9d 100644 --- a/server/traces/otel_converter.go +++ b/server/traces/otel_converter.go @@ -60,11 +60,35 @@ func ConvertOtelSpanIntoSpan(span *v1.Span) *model.Span { StartTime: startTime, EndTime: endTime, Parent: nil, + Events: extractEvents(span), Children: make([]*model.Span, 0), Attributes: attributes, } } +func extractEvents(v1 *v1.Span) []model.SpanEvent { + output := make([]model.SpanEvent, 0, len(v1.Events)) + for _, v1Event := range v1.Events { + attributes := make(model.Attributes, 0) + for _, attribute := range v1Event.Attributes { + attributes[attribute.Key] = getAttributeValue(attribute.Value) + } + var timestamp time.Time + + if v1Event.GetTimeUnixNano() != 0 { + timestamp = time.Unix(0, int64(v1Event.GetTimeUnixNano())) + } + + output = append(output, model.SpanEvent{ + Name: v1Event.Name, + Timestamp: timestamp, + Attributes: attributes, + }) + } + + return output +} + func spanKind(span *v1.Span) model.SpanKind { switch span.Kind { case v1.Span_SPAN_KIND_CLIENT: diff --git a/server/traces/otel_http_converter.go b/server/traces/otel_http_converter.go index cc2b4c8134..735442ed92 100644 --- a/server/traces/otel_http_converter.go +++ b/server/traces/otel_http_converter.go @@ -30,6 +30,7 @@ type httpSpan struct { StartTimeUnixNano string `json:"startTimeUnixNano"` EndTimeUnixNano string `json:"endTimeUnixNano"` Attributes []*httpSpanAttribute `json:"attributes"` + Events []*httpSpanEvent `json:"events"` Status *httpSpanStatus `json:"status"` } @@ -37,6 +38,12 @@ type httpSpanStatus struct { Code string `json:"code"` } +type httpSpanEvent struct { + Name string `json:"name"` + Timestamp string `json:"timeUnixNano"` + Attributes []*httpSpanAttribute `json:"attributes"` +} + type httpSpanAttribute struct { v11.KeyValue Value map[string]interface{} `json:"value"` @@ -90,7 +97,32 @@ func convertHttpOtelSpanIntoSpan(span *httpSpan) *model.Span { Parent: nil, Children: make([]*model.Span, 0), Attributes: attributes, + Events: extractEventsFromHttpSpan(span), + } +} + +func extractEventsFromHttpSpan(span *httpSpan) []model.SpanEvent { + output := make([]model.SpanEvent, 0, len(span.Events)) + for _, event := range span.Events { + attributes := make(model.Attributes, 0) + for _, attribute := range event.Attributes { + attributes[attribute.Key] = getHttpAttributeValue(attribute.Value) + } + + var timestamp time.Time + if event.Timestamp != "" { + timestampNs, _ := strconv.ParseInt(span.StartTimeUnixNano, 10, 64) + timestamp = time.Unix(0, timestampNs) + } + + output = append(output, model.SpanEvent{ + Name: event.Name, + Timestamp: timestamp, + Attributes: attributes, + }) } + + return output } func getHttpAttributeValue(value map[string]interface{}) string {