Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions internal/api/metrics_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,23 @@ func (s *Server) handleGetMetricNames(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(names)
}

// handleGetServices returns the list of services the caller's tenant has
// emitted any span for. Read from the in-memory GraphRAG ServiceStore so
// the dropdown matches /api/system/graph exactly — and so a service that
// only appears as a downstream callee (e.g. shipping-service deep in a
// fan-out) isn't silently dropped because some other span won the
// trace_id-uniqueness race for the legacy `traces` table query.
//
// Cold-start (first ~60s after restart, before the GraphRAG refresh loop
// rebuilds from DB) returns an empty list, which is correct: nothing has
// been ingested yet that the dropdown could meaningfully display.
func (s *Server) handleGetServices(w http.ResponseWriter, r *http.Request) {
services, err := s.repo.GetServices(r.Context())
if err != nil {
slog.Error("Failed to get services metadata", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
var services []string
if s.graphRAG != nil {
services = s.graphRAG.ServiceNames(r.Context())
}
if services == nil {
services = []string{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(services)
Expand Down
14 changes: 14 additions & 0 deletions internal/graphrag/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,20 @@ func (g *GraphRAG) AllServiceEdges(ctx context.Context) []*Edge {
return g.storesFor(ctx).service.AllEdges()
}

// ServiceNames returns every service the caller's tenant has emitted any
// span for, sorted ascending. Reads from the in-memory ServiceStore — no
// DB scan. Used by /api/metadata/services so the dropdown matches the
// system map exactly (both are sourced from the same store).
func (g *GraphRAG) ServiceNames(ctx context.Context) []string {
services := g.storesFor(ctx).service.AllServices()
names := make([]string, 0, len(services))
for _, svc := range services {
names = append(names, svc.Name)
}
sort.Strings(names)
return names
}

// ServiceMap returns the service topology with health scores for the API.
type ServiceMapEntry struct {
Service *ServiceNode `json:"service"`
Expand Down
86 changes: 86 additions & 0 deletions internal/graphrag/service_names_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package graphrag

import (
"context"
"reflect"
"testing"
"time"

"github.com/RandomCodeSpace/otelcontext/internal/storage"
)

// TestServiceNames_IncludesDeepCalleesAndIsTenantScoped covers the bug that
// motivated this method: prior to ServiceNames, /api/metadata/services
// queried `SELECT DISTINCT service_name FROM traces`, which silently dropped
// any service that only ever appeared as a callee (deep in a fan-out) — its
// span lost the trace_id-uniqueness race and never made it into the traces
// table. ServiceNames reads from the in-memory ServiceStore where every
// span — root or child — registers via UpsertService.
func TestServiceNames_IncludesDeepCalleesAndIsTenantScoped(t *testing.T) {
g := newTestGraphRAG(t)

now := time.Now()
mk := func(tenant, service, traceID, spanID, parentSpan string) storage.Span {
return storage.Span{
TenantID: tenant,
TraceID: traceID,
SpanID: spanID,
ParentSpanID: parentSpan,
OperationName: "/op",
ServiceName: service,
Status: "STATUS_CODE_OK",
StartTime: now,
EndTime: now.Add(time.Millisecond),
Duration: 1000,
}
}

// Tenant A: a 3-deep fan-out under one trace_id. order is the root,
// payment is a child, shipping is a grandchild — exactly the pattern
// where shipping previously got dropped from the dropdown.
g.OnSpanIngested(mk("tenant-a", "order", "t-a-1", "root", ""))
g.OnSpanIngested(mk("tenant-a", "payment", "t-a-1", "child", "root"))
g.OnSpanIngested(mk("tenant-a", "shipping", "t-a-1", "grand", "child"))

// Tenant B: a single root in a separate tenant. Must not leak into A.
g.OnSpanIngested(mk("tenant-b", "audit", "t-b-1", "root", ""))

ctxA := storage.WithTenantContext(context.Background(), "tenant-a")
ctxB := storage.WithTenantContext(context.Background(), "tenant-b")

// Async event workers — poll until both tenants have settled.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(g.ServiceNames(ctxA)) >= 3 && len(g.ServiceNames(ctxB)) >= 1 {
break
}
time.Sleep(20 * time.Millisecond)
}

gotA := g.ServiceNames(ctxA)
wantA := []string{"order", "payment", "shipping"} // sorted ascending
if !reflect.DeepEqual(gotA, wantA) {
t.Fatalf("tenant-a ServiceNames = %v, want %v (sorted, includes deep callee)", gotA, wantA)
}

gotB := g.ServiceNames(ctxB)
wantB := []string{"audit"}
if !reflect.DeepEqual(gotB, wantB) {
t.Fatalf("tenant-b ServiceNames = %v, want %v (no leak from tenant-a)", gotB, wantB)
}
}

// TestServiceNames_EmptyOnColdStart asserts that a freshly-constructed
// GraphRAG with no ingested spans returns an empty slice (never nil),
// matching the JSON-encoder expectation of `[]` rather than `null` so the
// UI dropdown stays a valid array on first paint.
func TestServiceNames_EmptyOnColdStart(t *testing.T) {
g := newTestGraphRAG(t)
got := g.ServiceNames(context.Background())
if got == nil {
t.Fatal("ServiceNames returned nil; want empty slice for json `[]` round-trip")
}
if len(got) != 0 {
t.Fatalf("ServiceNames on empty store = %v, want []", got)
}
}
8 changes: 4 additions & 4 deletions test/run_simulation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ $Services = @(
Push-Location $RootDir
foreach ($svc in $Services) {
Write-Host (" {0,-26}" -f $svc.Name) -NoNewline -ForegroundColor DarkGray
go build -o "$TmpDir\$($svc.Name).exe" "./test/$($svc.Name)" 2>&1 | Out-Null
go build -o (Join-Path $TmpDir "$($svc.Name).exe") "./test/$($svc.Name)" 2>&1 | Out-Null
Write-Host "built" -ForegroundColor DarkGray
}
Pop-Location
Expand All @@ -65,9 +65,9 @@ Write-Host "[2/3] Starting services..." -ForegroundColor Yellow

$Procs = @()
foreach ($svc in $Services) {
$exe = "$TmpDir\$($svc.Name).exe"
$stdout = "$LogDir\$($svc.Name).stdout"
$stderr = "$LogDir\$($svc.Name).stderr"
$exe = Join-Path $TmpDir "$($svc.Name).exe"
$stdout = Join-Path $LogDir "$($svc.Name).stdout"
$stderr = Join-Path $LogDir "$($svc.Name).stderr"
$proc = Start-Process -FilePath $exe -NoNewWindow -PassThru `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
Expand Down
Loading