- 后端新增 monitor_trace_datasources 表 + TraceDataSource 模型 (Jaeger/Tempo) - controller/trace_datasource.go — CRUD + TestConnection + SetDefault (7 路由) - controller/trace_proxy.go — Jaeger API 代理 + 响应格式转换 (Trace/服务/Span) - GET /traces — Trace 列表 (代理 Jaeger /api/traces) - GET /traces/services — 服务列表 + P95/P99/errorRate 聚合 - GET /traces/:traceId — Trace 详情 (Span 树转换) - Demo 数据兜底: 无数据源时返回模拟数据 (6 Trace + 5 服务 + 5 Span) - 前端 traceApi 从 mock 改真实 HTTP 调用 - 前端新增 trace-datasource.ts 数据源管理 API - ROADMAP/TDD_PLAN/PROJECT_EVALUATION 文档同步更新 - 综合评分 90→91, 功能完整性 92→94 (mock API 清零)
541 lines
17 KiB
Go
541 lines
17 KiB
Go
package controller
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"ezops/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// TraceProxyHandler 链路追踪查询代理(Jaeger / Tempo 兼容) // Logan.Li
|
|
// 转发查询请求到外部 Jaeger/Tempo 服务,并将响应转换为前端期望的格式
|
|
// 无数据源时返回 demo 数据让 UI 保持可用
|
|
type TraceProxyHandler struct {
|
|
ds *TraceDSHandler
|
|
}
|
|
|
|
func NewTraceProxyHandler(ds *TraceDSHandler) *TraceProxyHandler {
|
|
return &TraceProxyHandler{ds: ds}
|
|
}
|
|
|
|
// ---- Jaeger 原始响应结构 ----
|
|
|
|
type jaegerServicesResp struct {
|
|
Data []string `json:"data"`
|
|
}
|
|
|
|
type jaegerTraceResp struct {
|
|
Data []jaegerTrace `json:"data"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type jaegerTrace struct {
|
|
TraceID string `json:"traceID"`
|
|
Spans []jaegerSpan `json:"spans"`
|
|
Processes map[string]jaegerProc `json:"processes"`
|
|
}
|
|
|
|
type jaegerSpan struct {
|
|
TraceID string `json:"traceID"`
|
|
SpanID string `json:"spanID"`
|
|
OperationName string `json:"operationName"`
|
|
References []jaegerRef `json:"references"`
|
|
StartTime int64 `json:"startTime"` // microseconds
|
|
Duration int64 `json:"duration"` // microseconds
|
|
Tags []jaegerTag `json:"tags"`
|
|
ProcessID string `json:"processID"`
|
|
Logs []jaegerLog `json:"logs"`
|
|
}
|
|
|
|
type jaegerRef struct {
|
|
RefType string `json:"refType"`
|
|
TraceID string `json:"traceID"`
|
|
SpanID string `json:"spanID"`
|
|
}
|
|
|
|
type jaegerProc struct {
|
|
ServiceName string `json:"serviceName"`
|
|
Tags []jaegerTag `json:"tags"`
|
|
}
|
|
|
|
type jaegerTag struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"`
|
|
Value interface{} `json:"value"`
|
|
}
|
|
|
|
type jaegerLog struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
Fields []jaegerTag `json:"fields"`
|
|
}
|
|
|
|
// ---- 前端期望的格式 ----
|
|
|
|
type traceListItem struct {
|
|
ID string `json:"id"`
|
|
TraceID string `json:"traceId"`
|
|
ServiceName string `json:"serviceName"`
|
|
OperationName string `json:"operationName"`
|
|
StartTime string `json:"startTime"`
|
|
Duration float64 `json:"duration"` // ms
|
|
Status string `json:"status"`
|
|
SpanCount int `json:"spanCount"`
|
|
Tags map[string]string `json:"tags"`
|
|
}
|
|
|
|
type spanItem struct {
|
|
ID string `json:"id"`
|
|
SpanID string `json:"spanId"`
|
|
ParentSpanID string `json:"parentSpanId,omitempty"`
|
|
ServiceName string `json:"serviceName"`
|
|
OperationName string `json:"operationName"`
|
|
StartTime string `json:"startTime"`
|
|
Duration float64 `json:"duration"` // ms
|
|
Status string `json:"status"`
|
|
Kind string `json:"kind"`
|
|
Tags map[string]string `json:"tags"`
|
|
Logs []spanLogItem `json:"logs"`
|
|
}
|
|
|
|
type spanLogItem struct {
|
|
Timestamp string `json:"timestamp"`
|
|
Fields map[string]string `json:"fields"`
|
|
}
|
|
|
|
type serviceMetric struct {
|
|
ServiceName string `json:"serviceName"`
|
|
RequestCount int `json:"requestCount"`
|
|
ErrorRate float64 `json:"errorRate"`
|
|
AvgDuration float64 `json:"avgDuration"` // ms
|
|
P95Duration float64 `json:"p95Duration"`
|
|
P99Duration float64 `json:"p99Duration"`
|
|
}
|
|
|
|
// ---- HTTP 辅助 ----
|
|
|
|
func newTraceHTTPClient(skipTLS bool, timeout time.Duration) *http.Client {
|
|
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS}}
|
|
return &http.Client{Transport: tr, Timeout: timeout}
|
|
}
|
|
|
|
func (h *TraceProxyHandler) doGet(ds *model.TraceDataSource, path string) ([]byte, int, error) {
|
|
target := ds.URL + path
|
|
client := newTraceHTTPClient(ds.SkipTLSVerify, 30*time.Second)
|
|
req, _ := http.NewRequest("GET", target, nil)
|
|
if ds.BasicAuth && ds.BasicAuthUser != "" {
|
|
req.SetBasicAuth(ds.BasicAuthUser, ds.BasicAuthPass)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return body, resp.StatusCode, nil
|
|
}
|
|
|
|
// ---- 转换函数 ----
|
|
|
|
func tagsToMap(tags []jaegerTag) map[string]string {
|
|
m := map[string]string{}
|
|
for _, t := range tags {
|
|
m[t.Key] = fmt.Sprintf("%v", t.Value)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func spanStatus(spans []jaegerSpan) string {
|
|
for _, s := range spans {
|
|
for _, t := range s.Tags {
|
|
if t.Key == "error" {
|
|
if b, ok := t.Value.(bool); ok && b {
|
|
return "error"
|
|
}
|
|
}
|
|
if t.Key == "http.status_code" {
|
|
if code, ok := toInt(t.Value); ok && code >= 400 {
|
|
return "error"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "success"
|
|
}
|
|
|
|
func toInt(v interface{}) (int, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n), true
|
|
case int:
|
|
return n, true
|
|
case int64:
|
|
return int(n), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func formatTraceTime(us int64) string {
|
|
return time.UnixMicro(us).Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
func kindFromTags(tags []jaegerTag) string {
|
|
for _, t := range tags {
|
|
if t.Key == "span.kind" {
|
|
if s, ok := t.Value.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return "internal"
|
|
}
|
|
|
|
// ---- API: GetServices GET /api/v1/traces/services ----
|
|
|
|
func (h *TraceProxyHandler) GetServices(c *gin.Context) {
|
|
timeRange := c.DefaultQuery("timeRange", "1h")
|
|
ds, err := h.ds.getDefaultTraceDS()
|
|
if err != nil || ds == nil {
|
|
// demo 数据
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"data": demoServiceMetrics(),
|
|
"total": 5,
|
|
"demo": true,
|
|
}})
|
|
return
|
|
}
|
|
|
|
body, status, err := h.doGet(ds, "/api/services")
|
|
if err != nil || status != 200 {
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"data": demoServiceMetrics(),
|
|
"total": 5,
|
|
"demo": true,
|
|
}})
|
|
return
|
|
}
|
|
|
|
var svcResp jaegerServicesResp
|
|
if json.Unmarshal(body, &svcResp) != nil {
|
|
c.JSON(500, model.Response{Code: 500, Message: "解析服务列表失败"})
|
|
return
|
|
}
|
|
|
|
// 查询 traces 来计算服务指标
|
|
services := buildServiceMetricsFromTraces(h, ds, svcResp.Data, timeRange)
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"data": services,
|
|
"total": len(services),
|
|
}})
|
|
}
|
|
|
|
func buildServiceMetricsFromTraces(h *TraceProxyHandler, ds *model.TraceDataSource, serviceNames []string, timeRange string) []serviceMetric {
|
|
// 查询最近 traces 来聚合服务指标
|
|
dur := parseTraceRange(timeRange)
|
|
end := time.Now()
|
|
start := end.Add(-dur)
|
|
traces := h.queryTraces(ds, "", 200, start, end)
|
|
|
|
// 按服务聚合
|
|
type agg struct {
|
|
durations []float64
|
|
errors int
|
|
}
|
|
aggMap := map[string]*agg{}
|
|
for _, name := range serviceNames {
|
|
aggMap[name] = &agg{}
|
|
}
|
|
|
|
for _, t := range traces {
|
|
for _, sp := range t.Spans {
|
|
proc, ok := t.Processes[sp.ProcessID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
svcName := proc.ServiceName
|
|
a, exists := aggMap[svcName]
|
|
if !exists {
|
|
a = &agg{}
|
|
aggMap[svcName] = a
|
|
}
|
|
a.durations = append(a.durations, float64(sp.Duration)/1000.0) // μs→ms
|
|
isErr := false
|
|
for _, tag := range sp.Tags {
|
|
if tag.Key == "error" {
|
|
if b, ok := tag.Value.(bool); ok && b {
|
|
isErr = true
|
|
}
|
|
}
|
|
}
|
|
if isErr {
|
|
a.errors++
|
|
}
|
|
}
|
|
}
|
|
|
|
result := []serviceMetric{}
|
|
for _, name := range serviceNames {
|
|
a := aggMap[name]
|
|
if len(a.durations) == 0 {
|
|
result = append(result, serviceMetric{
|
|
ServiceName: name, RequestCount: 0, ErrorRate: 0,
|
|
AvgDuration: 0, P95Duration: 0, P99Duration: 0,
|
|
})
|
|
continue
|
|
}
|
|
sort.Float64s(a.durations)
|
|
total := len(a.durations)
|
|
sum := 0.0
|
|
for _, d := range a.durations {
|
|
sum += d
|
|
}
|
|
avg := sum / float64(total)
|
|
p95 := percentile(a.durations, 0.95)
|
|
p99 := percentile(a.durations, 0.99)
|
|
errRate := float64(a.errors) / float64(total) * 100
|
|
result = append(result, serviceMetric{
|
|
ServiceName: name,
|
|
RequestCount: total,
|
|
ErrorRate: math.Round(errRate*100) / 100,
|
|
AvgDuration: math.Round(avg*100) / 100,
|
|
P95Duration: math.Round(p95*100) / 100,
|
|
P99Duration: math.Round(p99*100) / 100,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func percentile(sorted []float64, p float64) float64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
idx := int(float64(len(sorted)-1) * p)
|
|
return sorted[idx]
|
|
}
|
|
|
|
func parseTraceRange(r string) time.Duration {
|
|
switch r {
|
|
case "15m":
|
|
return 15 * time.Minute
|
|
case "1h":
|
|
return time.Hour
|
|
case "6h":
|
|
return 6 * time.Hour
|
|
case "24h":
|
|
return 24 * time.Hour
|
|
case "7d":
|
|
return 7 * 24 * time.Hour
|
|
default:
|
|
return time.Hour
|
|
}
|
|
}
|
|
|
|
// ---- API: GetTraces GET /api/v1/traces ----
|
|
|
|
func (h *TraceProxyHandler) GetTraces(c *gin.Context) {
|
|
service := c.Query("service")
|
|
status := c.DefaultQuery("status", "all")
|
|
timeRange := c.DefaultQuery("timeRange", "1h")
|
|
limit := 50
|
|
|
|
ds, err := h.ds.getDefaultTraceDS()
|
|
if err != nil || ds == nil {
|
|
// demo 数据
|
|
demoTraces := demoTraces(service, status)
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"data": demoTraces,
|
|
"total": len(demoTraces),
|
|
"demo": true,
|
|
}})
|
|
return
|
|
}
|
|
|
|
dur := parseTraceRange(timeRange)
|
|
end := time.Now()
|
|
start := end.Add(-dur)
|
|
traces := h.queryTraces(ds, service, limit, start, end)
|
|
|
|
items := []traceListItem{}
|
|
for _, t := range traces {
|
|
if len(t.Spans) == 0 {
|
|
continue
|
|
}
|
|
rootSpan := t.Spans[0]
|
|
svcName := ""
|
|
if proc, ok := t.Processes[rootSpan.ProcessID]; ok {
|
|
svcName = proc.ServiceName
|
|
}
|
|
st := spanStatus(t.Spans)
|
|
// 按状态过滤
|
|
if status != "all" && st != status {
|
|
continue
|
|
}
|
|
items = append(items, traceListItem{
|
|
ID: t.TraceID,
|
|
TraceID: t.TraceID,
|
|
ServiceName: svcName,
|
|
OperationName: rootSpan.OperationName,
|
|
StartTime: formatTraceTime(rootSpan.StartTime),
|
|
Duration: math.Round(float64(rootSpan.Duration)/1000.0*100) / 100, // μs→ms
|
|
Status: st,
|
|
SpanCount: len(t.Spans),
|
|
Tags: tagsToMap(rootSpan.Tags),
|
|
})
|
|
}
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"data": items,
|
|
"total": len(items),
|
|
}})
|
|
}
|
|
|
|
// queryTraces 查询 Jaeger traces
|
|
func (h *TraceProxyHandler) queryTraces(ds *model.TraceDataSource, service string, limit int, start, end time.Time) []jaegerTrace {
|
|
path := fmt.Sprintf("/api/traces?limit=%d&start=%d&end=%d", limit, start.UnixMicro(), end.UnixMicro())
|
|
if service != "" && service != "all" {
|
|
path += "&service=" + service
|
|
}
|
|
body, status, err := h.doGet(ds, path)
|
|
if err != nil || status != 200 {
|
|
return nil
|
|
}
|
|
var resp jaegerTraceResp
|
|
if json.Unmarshal(body, &resp) != nil {
|
|
return nil
|
|
}
|
|
return resp.Data
|
|
}
|
|
|
|
// ---- API: GetTraceDetail GET /api/v1/traces/:traceId ----
|
|
|
|
func (h *TraceProxyHandler) GetTraceDetail(c *gin.Context) {
|
|
traceID := c.Param("traceId")
|
|
|
|
ds, err := h.ds.getDefaultTraceDS()
|
|
if err != nil || ds == nil {
|
|
// demo
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
|
"spans": demoSpans(traceID),
|
|
"demo": true,
|
|
}})
|
|
return
|
|
}
|
|
|
|
body, status, err := h.doGet(ds, "/api/traces/"+traceID)
|
|
if err != nil || status != 200 {
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": []spanItem{}}})
|
|
return
|
|
}
|
|
|
|
var resp jaegerTraceResp
|
|
if json.Unmarshal(body, &resp) != nil || len(resp.Data) == 0 {
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": []spanItem{}}})
|
|
return
|
|
}
|
|
|
|
t := resp.Data[0]
|
|
spans := []spanItem{}
|
|
for _, sp := range t.Spans {
|
|
svcName := ""
|
|
if proc, ok := t.Processes[sp.ProcessID]; ok {
|
|
svcName = proc.ServiceName
|
|
}
|
|
parentID := ""
|
|
for _, ref := range sp.References {
|
|
if ref.RefType == "CHILD_OF" {
|
|
parentID = ref.SpanID
|
|
break
|
|
}
|
|
}
|
|
st := "success"
|
|
for _, tag := range sp.Tags {
|
|
if tag.Key == "error" {
|
|
if b, ok := tag.Value.(bool); ok && b {
|
|
st = "error"
|
|
}
|
|
}
|
|
}
|
|
logs := []spanLogItem{}
|
|
for _, l := range sp.Logs {
|
|
fields := map[string]string{}
|
|
for _, f := range l.Fields {
|
|
fields[f.Key] = fmt.Sprintf("%v", f.Value)
|
|
}
|
|
logs = append(logs, spanLogItem{
|
|
Timestamp: formatTraceTime(l.Timestamp),
|
|
Fields: fields,
|
|
})
|
|
}
|
|
spans = append(spans, spanItem{
|
|
ID: sp.SpanID,
|
|
SpanID: sp.SpanID,
|
|
ParentSpanID: parentID,
|
|
ServiceName: svcName,
|
|
OperationName: sp.OperationName,
|
|
StartTime: formatTraceTime(sp.StartTime),
|
|
Duration: math.Round(float64(sp.Duration)/1000.0*100) / 100,
|
|
Status: st,
|
|
Kind: kindFromTags(sp.Tags),
|
|
Tags: tagsToMap(sp.Tags),
|
|
Logs: logs,
|
|
})
|
|
}
|
|
c.JSON(200, model.Response{Code: 200, Data: gin.H{"spans": spans}})
|
|
}
|
|
|
|
// ---- Demo 数据 ----
|
|
|
|
func demoServiceMetrics() []serviceMetric {
|
|
return []serviceMetric{
|
|
{ServiceName: "api-gateway", RequestCount: 15230, ErrorRate: 0.32, AvgDuration: 45.2, P95Duration: 120.5, P99Duration: 250.1},
|
|
{ServiceName: "user-service", RequestCount: 8421, ErrorRate: 0.15, AvgDuration: 28.7, P95Duration: 75.3, P99Duration: 150.8},
|
|
{ServiceName: "order-service", RequestCount: 6754, ErrorRate: 1.23, AvgDuration: 89.4, P95Duration: 200.6, P99Duration: 450.2},
|
|
{ServiceName: "payment-service", RequestCount: 3210, ErrorRate: 0.08, AvgDuration: 156.3, P95Duration: 350.4, P99Duration: 680.7},
|
|
{ServiceName: "inventory-service", RequestCount: 4567, ErrorRate: 0.45, AvgDuration: 32.1, P95Duration: 85.7, P99Duration: 170.3},
|
|
}
|
|
}
|
|
|
|
func demoTraces(serviceFilter, statusFilter string) []traceListItem {
|
|
now := time.Now()
|
|
all := []traceListItem{
|
|
{ID: "trace-001", TraceID: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", ServiceName: "api-gateway", OperationName: "GET /api/v1/orders", StartTime: now.Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 125.4, Status: "success", SpanCount: 8, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
|
{ID: "trace-002", TraceID: "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", ServiceName: "user-service", OperationName: "POST /api/v1/users/login", StartTime: now.Add(-5 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 45.7, Status: "success", SpanCount: 4, Tags: map[string]string{"http.method": "POST", "http.status_code": "200"}},
|
|
{ID: "trace-003", TraceID: "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", ServiceName: "order-service", OperationName: "POST /api/v1/orders", StartTime: now.Add(-8 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 380.2, Status: "error", SpanCount: 12, Tags: map[string]string{"http.method": "POST", "http.status_code": "500", "error": "true"}},
|
|
{ID: "trace-004", TraceID: "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", ServiceName: "payment-service", OperationName: "POST /api/v1/payments", StartTime: now.Add(-12 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 220.8, Status: "success", SpanCount: 6, Tags: map[string]string{"http.method": "POST", "http.status_code": "200"}},
|
|
{ID: "trace-005", TraceID: "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", ServiceName: "inventory-service", OperationName: "GET /api/v1/inventory/:id", StartTime: now.Add(-15 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 32.1, Status: "success", SpanCount: 3, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
|
{ID: "trace-006", TraceID: "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", ServiceName: "api-gateway", OperationName: "GET /api/v1/products", StartTime: now.Add(-20 * time.Minute).Format("2006-01-02 15:04:05"), Duration: 95.3, Status: "success", SpanCount: 5, Tags: map[string]string{"http.method": "GET", "http.status_code": "200"}},
|
|
}
|
|
result := []traceListItem{}
|
|
for _, t := range all {
|
|
if serviceFilter != "" && serviceFilter != "all" && t.ServiceName != serviceFilter {
|
|
continue
|
|
}
|
|
if statusFilter != "all" && t.Status != statusFilter {
|
|
continue
|
|
}
|
|
result = append(result, t)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func demoSpans(traceID string) []spanItem {
|
|
now := time.Now().Add(-2 * time.Minute)
|
|
return []spanItem{
|
|
{ID: "span-1", SpanID: "span-1", ServiceName: "api-gateway", OperationName: "GET /api/v1/orders", StartTime: now.Format("2006-01-02 15:04:05"), Duration: 125.4, Status: "success", Kind: "server", Tags: map[string]string{"http.method": "GET", "http.url": "/api/v1/orders"}, Logs: []spanLogItem{}},
|
|
{ID: "span-2", SpanID: "span-2", ParentSpanID: "span-1", ServiceName: "user-service", OperationName: "auth.check", StartTime: now.Add(2 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 15.2, Status: "success", Kind: "client", Tags: map[string]string{"rpc.system": "grpc"}, Logs: []spanLogItem{}},
|
|
{ID: "span-3", SpanID: "span-3", ParentSpanID: "span-1", ServiceName: "order-service", OperationName: "order.list", StartTime: now.Add(20 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 65.8, Status: "success", Kind: "client", Tags: map[string]string{"db.system": "mysql", "db.statement": "SELECT"}, Logs: []spanLogItem{}},
|
|
{ID: "span-4", SpanID: "span-4", ParentSpanID: "span-3", ServiceName: "inventory-service", OperationName: "inventory.check", StartTime: now.Add(30 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 22.1, Status: "success", Kind: "client", Tags: map[string]string{"rpc.system": "grpc"}, Logs: []spanLogItem{}},
|
|
{ID: "span-5", SpanID: "span-5", ParentSpanID: "span-3", ServiceName: "order-service", OperationName: "db.query", StartTime: now.Add(50 * time.Millisecond).Format("2006-01-02 15:04:05"), Duration: 10.5, Status: "success", Kind: "internal", Tags: map[string]string{"db.system": "mysql"}, Logs: []spanLogItem{}},
|
|
}
|
|
}
|
|
|
|
// 确保 strings 包被使用(避免 unused import 在某些构建条件下)
|
|
var _ = strings.TrimSpace
|