Files
go-otel/internal/collector/collector_test.go
T
mats 0ed2935f04
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 36s
alot more changes
2026-09-21 18:59:13 +02:00

681 lines
22 KiB
Go

package collector
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.tornberg.me/go-otel/internal/issues"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/websocket"
"go.opentelemetry.io/collector/pdata/pcommon"
"go.opentelemetry.io/collector/pdata/plog"
"go.opentelemetry.io/collector/pdata/ptrace"
)
type noopBroadcaster struct{}
func (noopBroadcaster) Broadcast(entry *websocket.Entry) {}
// mockStore is used by tests only; addToHistory appends to the store
// asynchronously, so access must be synchronized even in single-threaded
// test flows that poll for the async write to land.
type mockStore struct {
mu sync.Mutex
entries []websocket.HistoryEntry
}
func (m *mockStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
m.mu.Lock()
defer m.mu.Unlock()
m.entries = append(m.entries, entry)
return nil
}
func (m *mockStore) len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.entries)
}
func (m *mockStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.entries, nil
}
func (m *mockStore) Search(ctx context.Context, opts storage.SearchOptions, limit int) ([]websocket.HistoryEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
var filtered []websocket.HistoryEntry
for _, e := range m.entries {
if opts.Type != "" && e.Type != opts.Type {
continue
}
if opts.Query != "" && !strings.Contains(string(e.Data), opts.Query) {
continue
}
filtered = append(filtered, e)
}
return filtered, nil
}
func TestCollectorWithStore(t *testing.T) {
store := &mockStore{}
coll := New(nil, 10, store)
coll.addToHistory(websocket.DataTypeLogs, json.RawMessage(`{"msg": "test"}`))
// Give it a moment since it's asynchronous
timeout := time.After(100 * time.Millisecond)
for {
select {
case <-timeout:
t.Fatal("timed out waiting for entry to be appended to store")
default:
if store.len() > 0 {
goto OK
}
time.Sleep(10 * time.Millisecond)
}
}
OK:
entries, _ := store.GetHistory(context.Background(), 0)
if entries[0].Type != "logs" {
t.Errorf("expected type 'logs', got %s", entries[0].Type)
}
}
func TestCollectorHistoryLimit(t *testing.T) {
coll := New(nil, 10, nil)
for i := 0; i < 10; i++ {
coll.addToHistory(websocket.DataTypeLogs, json.RawMessage(`{"msg": "test"}`))
}
history := coll.GetHistory(10)
if len(history) != 10 {
t.Errorf("expected 10 entries, got %d", len(history))
}
history = coll.GetHistory(5)
if len(history) != 5 {
t.Errorf("expected 5 entries, got %d", len(history))
}
// Should default to historySize (10)
history = coll.GetHistory(0)
if len(history) != 10 {
t.Errorf("expected default limit to be historySize (10), got %d", len(history))
}
// Should not crash on large limit, just return what it has
history = coll.GetHistory(100)
if len(history) != 10 {
t.Errorf("expected 10 entries even with limit 100, got %d", len(history))
}
}
func TestHandleHistoryByTypeUsesStore(t *testing.T) {
store := &mockStore{
entries: []websocket.HistoryEntry{
{Type: "traces", Timestamp: 1, Data: json.RawMessage(`{"id":1}`)},
{Type: "traces", Timestamp: 2, Data: json.RawMessage(`{"id":2}`)},
{Type: "logs", Timestamp: 3, Data: json.RawMessage(`{"id":3}`)},
},
}
// Ring buffer is much smaller than the store's content, so a correct
// implementation must route through the store rather than the ring buffer.
coll := New(nil, 1, store)
req := httptest.NewRequest(http.MethodGet, "/api/history/traces", nil)
rec := httptest.NewRecorder()
coll.HandleHistoryByType("traces")(rec, req)
var results []websocket.HistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 trace entries from the store, got %d", len(results))
}
}
func TestHandleTraceByID(t *testing.T) {
store := &mockStore{
entries: []websocket.HistoryEntry{
{Type: "traces", Timestamp: 1, Data: json.RawMessage(`{"traceId":"abc123"}`)},
{Type: "logs", Timestamp: 2, Data: json.RawMessage(`{"traceId":"abc123","msg":"related"}`)},
{Type: "traces", Timestamp: 3, Data: json.RawMessage(`{"traceId":"other"}`)},
},
}
coll := New(nil, 10, store)
req := httptest.NewRequest(http.MethodGet, "/api/traces/abc123", nil)
req.SetPathValue("traceId", "abc123")
rec := httptest.NewRecorder()
coll.HandleTraceByID(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var results []websocket.HistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 entries referencing trace abc123, got %d", len(results))
}
}
func TestHandleWaitRateLimited(t *testing.T) {
coll := New(nil, 10, nil)
var lastCode int
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/history/wait?q=nope&timeout=0", nil)
req.RemoteAddr = "203.0.113.1:12345"
rec := httptest.NewRecorder()
coll.HandleWait(rec, req)
lastCode = rec.Code
if lastCode == http.StatusTooManyRequests {
break
}
}
if lastCode != http.StatusTooManyRequests {
t.Fatalf("expected rate limiting to eventually return 429, last code was %d", lastCode)
}
}
func TestConsumeTracesRecordsIssue(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
td := ptrace.NewTraces()
rs := td.ResourceSpans().AppendEmpty()
rs.Resource().Attributes().PutStr("service.name", "checkout")
span := rs.ScopeSpans().AppendEmpty().Spans().AppendEmpty()
span.SetName("charge-card")
span.Status().SetCode(ptrace.StatusCodeError)
span.Status().SetMessage("card declined")
span.SetTraceID(pcommon.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
if err := coll.ConsumeTraces(context.Background(), td); err != nil {
t.Fatalf("ConsumeTraces failed: %v", err)
}
list := coll.ListIssues(10)
if len(list) != 1 {
t.Fatalf("expected 1 issue, got %d", len(list))
}
if list[0].Service != "checkout" {
t.Errorf("expected service 'checkout', got %s", list[0].Service)
}
if list[0].Kind != "span_error" {
t.Errorf("expected kind 'span_error', got %s", list[0].Kind)
}
if !strings.Contains(list[0].Title, "card declined") {
t.Errorf("expected title to contain status message, got %q", list[0].Title)
}
if list[0].Samples[0].TraceID == "" {
t.Errorf("expected sample to carry the trace id")
}
// A non-error span must not create an issue.
okSpan := rs.ScopeSpans().At(0).Spans().AppendEmpty()
okSpan.SetName("healthy-op")
okSpan.Status().SetCode(ptrace.StatusCodeOk)
if err := coll.ConsumeTraces(context.Background(), td); err != nil {
t.Fatalf("ConsumeTraces failed: %v", err)
}
// Re-sending the same error span dedupes into the existing issue.
list = coll.ListIssues(10)
if len(list) != 1 || list[0].Count != 2 {
t.Fatalf("expected deduped issue with count 2, got %+v", list)
}
}
func TestConsumeTracesHTTPErrorsAndNaming(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
td := ptrace.NewTraces()
// 1. Nginx span with 404 and empty name (common for ingress/reverse proxy)
rs1 := td.ResourceSpans().AppendEmpty()
rs1.Resource().Attributes().PutStr("service.name", "nginx")
s1 := rs1.ScopeSpans().AppendEmpty().Spans().AppendEmpty()
s1.SetName("") // empty name
s1.Attributes().PutInt("http.status_code", 404)
s1.Attributes().PutStr("http.method", "GET")
s1.Attributes().PutStr("http.target", "/missing-path")
// 2. e2e-verify span with 500 and service name in span attributes
rs2 := td.ResourceSpans().AppendEmpty()
// Resource has no service.name, fallback to app attribute on span
s2 := rs2.ScopeSpans().AppendEmpty().Spans().AppendEmpty()
s2.SetName("test-endpoint")
s2.Attributes().PutStr("app.kubernetes.io/name", "e2e-verify")
s2.Attributes().PutInt("http.response.status_code", 500)
s2.Attributes().PutStr("http.request.method", "POST")
s2.Attributes().PutStr("http.route", "/api/v1/test")
if err := coll.ConsumeTraces(context.Background(), td); err != nil {
t.Fatalf("ConsumeTraces failed: %v", err)
}
issuesList := coll.ListIssues(10)
if len(issuesList) != 2 {
t.Fatalf("expected 2 distinct issues (404 and 500), got %d: %+v", len(issuesList), issuesList)
}
foundNginx := false
foundE2E := false
for _, iss := range issuesList {
if iss.Title == "(error)" {
t.Errorf("issue title must not be '(error)', got %q", iss.Title)
}
if iss.Service == "unknown" {
t.Errorf("service must not be 'unknown', got %s", iss.Service)
}
if iss.Service == "nginx" {
foundNginx = true
if !strings.Contains(iss.Title, "HTTP 404 (Not Found): GET /missing-path") {
t.Errorf("expected nginx 404 title, got %q", iss.Title)
}
if iss.Samples[0].HTTPStatus != 404 {
t.Errorf("expected sample HTTPStatus 404, got %d", iss.Samples[0].HTTPStatus)
}
}
if iss.Service == "e2e-verify" {
foundE2E = true
if !strings.Contains(iss.Title, "HTTP 500 (Internal Server Error): POST /api/v1/test") {
t.Errorf("expected e2e-verify 500 title, got %q", iss.Title)
}
if iss.Samples[0].HTTPStatus != 500 {
t.Errorf("expected sample HTTPStatus 500, got %d", iss.Samples[0].HTTPStatus)
}
}
}
if !foundNginx {
t.Errorf("expected to find nginx issue")
}
if !foundE2E {
t.Errorf("expected to find e2e-verify issue")
}
}
func TestConsumeLogsRecordsIssue(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
ld := plog.NewLogs()
rl := ld.ResourceLogs().AppendEmpty()
rl.Resource().Attributes().PutStr("service.name", "api")
record := rl.ScopeLogs().AppendEmpty().LogRecords().AppendEmpty()
record.SetSeverityNumber(plog.SeverityNumberError)
record.Body().SetStr("panic: nil pointer dereference")
// An info-level record must not create an issue.
infoRecord := rl.ScopeLogs().At(0).LogRecords().AppendEmpty()
infoRecord.SetSeverityNumber(plog.SeverityNumberInfo)
infoRecord.Body().SetStr("request handled")
if err := coll.ConsumeLogs(context.Background(), ld); err != nil {
t.Fatalf("ConsumeLogs failed: %v", err)
}
list := coll.ListIssues(10)
if len(list) != 1 {
t.Fatalf("expected 1 issue, got %d", len(list))
}
if list[0].Kind != "log_error" {
t.Errorf("expected kind 'log_error', got %s", list[0].Kind)
}
if list[0].Title != "panic: nil pointer dereference" {
t.Errorf("unexpected title: %q", list[0].Title)
}
}
func TestHandleIssuesAndByID(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
ld := plog.NewLogs()
rl := ld.ResourceLogs().AppendEmpty()
rl.Resource().Attributes().PutStr("service.name", "api")
record := rl.ScopeLogs().AppendEmpty().LogRecords().AppendEmpty()
record.SetSeverityNumber(plog.SeverityNumberError)
record.Body().SetStr("boom")
if err := coll.ConsumeLogs(context.Background(), ld); err != nil {
t.Fatalf("ConsumeLogs failed: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/issues", nil)
rec := httptest.NewRecorder()
coll.HandleIssues(rec, req)
var list []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("failed to decode issues response: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 issue in response, got %d", len(list))
}
id, _ := list[0]["id"].(string)
if id == "" {
t.Fatalf("expected issue to have an id, got %+v", list[0])
}
req = httptest.NewRequest(http.MethodGet, "/api/issues/"+id, nil)
req.SetPathValue("id", id)
rec = httptest.NewRecorder()
coll.HandleIssueByID(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for known issue id, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodPatch, "/api/issues/"+id, bytes.NewBufferString(`{"status":"resolved"}`))
req.SetPathValue("id", id)
rec = httptest.NewRecorder()
coll.HandleIssueByID(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 when resolving known issue, got %d: %s", rec.Code, rec.Body.String())
}
var updated map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
t.Fatalf("failed to decode updated issue response: %v", err)
}
if updated["status"] != "resolved" {
t.Fatalf("expected resolved issue, got %+v", updated["status"])
}
req = httptest.NewRequest(http.MethodGet, "/api/issues/missing", nil)
req.SetPathValue("id", "missing")
rec = httptest.NewRecorder()
coll.HandleIssueByID(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown issue id, got %d", rec.Code)
}
}
func TestIssueTrendsUseTelemetryTimestamps(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
base := time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC)
ld := plog.NewLogs()
rl := ld.ResourceLogs().AppendEmpty()
rl.Resource().Attributes().PutStr("service.name", "api")
records := rl.ScopeLogs().AppendEmpty().LogRecords()
first := records.AppendEmpty()
first.SetTimestamp(pcommon.NewTimestampFromTime(base))
first.SetSeverityNumber(plog.SeverityNumberError)
first.Body().SetStr("first failure")
second := records.AppendEmpty()
second.SetTimestamp(pcommon.NewTimestampFromTime(base.Add(time.Hour)))
second.SetSeverityNumber(plog.SeverityNumberError)
second.Body().SetStr("second failure")
if err := coll.ConsumeLogs(context.Background(), ld); err != nil {
t.Fatalf("ConsumeLogs failed: %v", err)
}
list := coll.ListIssues(0)
if len(list) != 2 {
t.Fatalf("expected two issues, got %d", len(list))
}
for _, issue := range list {
if len(issue.Trend) != 1 {
t.Fatalf("expected one trend bucket for %q, got %+v", issue.Title, issue.Trend)
}
}
expected := map[string]string{
"first failure": issues.HourBucket(base.UnixNano()),
"second failure": issues.HourBucket(base.Add(time.Hour).UnixNano()),
}
for _, issue := range list {
if _, ok := issue.Trend[expected[issue.Title]]; !ok {
t.Fatalf("telemetry timestamp was not used for %q: %+v", issue.Title, issue.Trend)
}
}
}
func TestHandleMLDetectorTest(t *testing.T) {
coll := New(nil, 10, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ml/test-spike", nil)
rec := httptest.NewRecorder()
coll.HandleMLDetectorTest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected detector test success, got %d: %s", rec.Code, rec.Body.String())
}
var result struct {
Passed bool `json:"passed"`
Row struct {
LabelKnown bool `json:"labelKnown"`
Label bool `json:"label"`
BaselineErrorRate float64 `json:"baselineErrorRate"`
FutureErrorCount int `json:"futureErrorCount"`
} `json:"row"`
}
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if !result.Passed || !result.Row.LabelKnown || !result.Row.Label || result.Row.BaselineErrorRate != .1 || result.Row.FutureErrorCount != 5 {
t.Fatalf("unexpected detector verification response: %+v", result)
}
}
func TestHandleIssuesFiltersAndPaginates(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
coll.issues.Record("span_error", "nginx", "GET /.env", issues.Sample{Timestamp: 300, SourceIP: "203.0.113.10", HTTPRoute: "/.env"})
coll.issues.Record("span_error", "nginx", "GET /.git/HEAD", issues.Sample{Timestamp: 200, SourceIP: "203.0.113.11", HTTPRoute: "/.git/HEAD"})
coll.issues.Record("log_error", "api", "database down", issues.Sample{Timestamp: 100})
req := httptest.NewRequest(http.MethodGet, "/api/issues?service=nginx&kind=span_error&limit=1", nil)
rec := httptest.NewRecorder()
coll.HandleIssues(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var page []issues.Issue
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatalf("failed to decode issues page: %v", err)
}
if len(page) != 1 || page[0].Service != "nginx" {
t.Fatalf("unexpected filtered page: %+v", page)
}
cursor := rec.Header().Get("X-Next-Cursor")
if cursor == "" {
t.Fatal("expected next cursor header")
}
req = httptest.NewRequest(http.MethodGet, "/api/issues?service=nginx&limit=1&cursor="+cursor, nil)
rec = httptest.NewRecorder()
coll.HandleIssues(rec, req)
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatalf("failed to decode cursor page: %v", err)
}
if len(page) != 1 || page[0].Title != "GET /.git/HEAD" {
t.Fatalf("unexpected cursor page: %+v", page)
}
}
func TestHandleLogsIngestKey(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
coll.SetIngestKey("secret")
body := []byte(`{"resourceLogs":[]}`)
cases := []struct {
name string
url string
header string
wantStatus int
}{
{"no key", "/v1/logs", "", http.StatusUnauthorized},
{"wrong key", "/v1/logs?key=wrong", "", http.StatusUnauthorized},
{"correct key in query", "/v1/logs?key=secret", "", http.StatusOK},
{"correct key in header", "/v1/logs", "secret", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if tc.header != "" {
req.Header.Set("X-Otel-Ingest-Key", tc.header)
}
// Shared IP across subtests; the ingest limiter's burst of 50 is
// well above this test's request count.
req.RemoteAddr = "198.51.100.1:1234"
rec := httptest.NewRecorder()
coll.HandleLogs(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("expected status %d, got %d (body=%s)", tc.wantStatus, rec.Code, rec.Body.String())
}
})
}
}
func TestHandleLogsPodGuardianIntegration(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
coll.SetIngestKey("ingest-secret")
// Payload matching what fervent-franklin (k8s-pod-guardian) sends
payload := `{
"resourceLogs": [
{
"resource": {
"attributes": [
{ "key": "service.name", "value": { "stringValue": "payment-service" } },
{ "key": "service.environment", "value": { "stringValue": "production" } },
{ "key": "telemetry.source", "value": { "stringValue": "k8s-pod-guardian" } },
{ "key": "k8s.namespace.name", "value": { "stringValue": "default" } },
{ "key": "k8s.pod.name", "value": { "stringValue": "payment-api-6789-xyz" } },
{ "key": "k8s.container.name", "value": { "stringValue": "payment-api" } },
{ "key": "host.name", "value": { "stringValue": "worker-1" } }
]
},
"scopeLogs": [
{
"scope": { "name": "k8s-pod-guardian/crashloop-harvester" },
"logRecords": [
{
"timeUnixNano": "1779362400000000000",
"severityNumber": 17,
"severityText": "ERROR",
"body": {
"stringValue": "panic: database connection failed: dial tcp 10.96.0.10:5432: i/o timeout"
},
"attributes": [
{ "key": "k8s.container.restart_count", "value": { "stringValue": "5" } },
{ "key": "crashloop.reason", "value": { "stringValue": "CrashLoopBackOff" } }
]
}
]
}
]
}
]
}`
req := httptest.NewRequest(http.MethodPost, "/v1/logs", bytes.NewReader([]byte(payload)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Otel-Ingest-Key", "ingest-secret")
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
coll.HandleLogs(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d (body=%s)", rec.Code, rec.Body.String())
}
issues := coll.ListIssues(10)
if len(issues) != 1 {
t.Fatalf("expected 1 issue created from crash log, got %d", len(issues))
}
if issues[0].Service != "payment-service" {
t.Errorf("expected issue service 'payment-service', got %q", issues[0].Service)
}
if !strings.Contains(issues[0].Title, "panic: database connection failed") {
t.Errorf("unexpected issue title: %q", issues[0].Title)
}
}
func TestHandleLogsIngestRateLimited(t *testing.T) {
coll := New(noopBroadcaster{}, 10, nil)
body := []byte(`{"resourceLogs":[]}`)
var lastCode int
for i := 0; i < 80; i++ {
req := httptest.NewRequest(http.MethodPost, "/v1/logs", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "198.51.100.2:1234"
rec := httptest.NewRecorder()
coll.HandleLogs(rec, req)
lastCode = rec.Code
if lastCode == http.StatusTooManyRequests {
break
}
}
if lastCode != http.StatusTooManyRequests {
t.Fatalf("expected ingest rate limiting to eventually return 429, last code was %d", lastCode)
}
}
func TestHandleVitalsStoresOnePageVitalsRecord(t *testing.T) {
c := New(noopBroadcaster{}, 100, &mockStore{})
body := `{"service":"shop","session":"s1","pageView":"pv1","url":"https://x/y","nav":"navigate",
"metrics":{"LCP":{"value":3100.4,"attribution":{"target":"img.hero","nested":{"x":1}}},
"CLS":{"value":0.05},"BOGUS":{"value":1},"INP":{"value":-3}}}`
req := httptest.NewRequest(http.MethodPost, "/v1/vitals", strings.NewReader(body))
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
rr := httptest.NewRecorder()
c.HandleVitals(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
}
vb := vitalsBeacon{}
if err := json.Unmarshal([]byte(body), &vb); err != nil {
t.Fatal(err)
}
logs, ok := vb.toLogs(time.Unix(1, 0))
if !ok {
t.Fatal("expected valid logs")
}
rec := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0)
attrs := rec.Attributes()
if v, _ := attrs.Get("vitals.lcp.rating"); v.Str() != "needs-improvement" {
t.Errorf("lcp rating = %q", v.Str())
}
if v, _ := attrs.Get("vitals.cls"); v.Double() != 0.05 {
t.Errorf("cls = %v (must not be rounded)", v.Double())
}
if _, found := attrs.Get("vitals.inp"); found {
t.Error("negative INP must be dropped")
}
if strings.Contains(rec.Body().Str(), "BOGUS") || strings.Contains(rec.Body().Str(), "nested") {
t.Errorf("unknown metric or non-scalar attribution leaked: %s", rec.Body().Str())
}
}
func TestHandleVitalsRejectsEmpty(t *testing.T) {
c := New(noopBroadcaster{}, 100, &mockStore{})
req := httptest.NewRequest(http.MethodPost, "/v1/vitals", strings.NewReader(`{"metrics":{}}`))
rr := httptest.NewRecorder()
c.HandleVitals(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d", rr.Code)
}
}