package parser_test import ( "testing" "time" "tangled.org/samatkins.net/dockscope/internal/parser" ) func TestJSONParser(t *testing.T) { t.Parallel() t.Run("GIVEN slog JSON with trace_id WHEN parsed THEN TraceID and SpanID are extracted", func(t *testing.T) { t.Parallel() raw := `{"time":"2026-05-09T14:32:01.088Z","level":"INFO","msg":"order created","trace_id":"abc123def4567890","span_id":"def456","user":"sam","order_id":4201}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.TraceID, "abc123def4567890"; got != want { t.Errorf("TraceID = %q, want %q", got, want) } if got, want := entry.SpanID, "def456"; got != want { t.Errorf("SpanID = %q, want %q", got, want) } if got, want := entry.Level, "INFO"; got != want { t.Errorf("Level = %q, want %q", got, want) } if got, want := entry.Message, "order created"; got != want { t.Errorf("Message = %q, want %q", got, want) } if got, want := entry.Raw, raw; got != want { t.Errorf("Raw = %q, want %q", got, want) } expectedTime := time.Date(2026, 5, 9, 14, 32, 1, 88000000, time.UTC) if !entry.Timestamp.Equal(expectedTime) { t.Errorf("Timestamp = %v, want %v", entry.Timestamp, expectedTime) } if user, ok := entry.Fields["user"]; !ok || user != "sam" { t.Errorf("Fields[user] = %v, want 'sam'", user) } if orderID, ok := entry.Fields["order_id"]; !ok || orderID != float64(4201) { t.Errorf("Fields[order_id] = %v, want 4201", orderID) } }) t.Run("GIVEN JSON with traceparent WHEN parsed THEN trace ID is normalized from W3C format", func(t *testing.T) { t.Parallel() raw := `{"time":"2026-05-09T14:32:01Z","level":"info","msg":"request received","traceparent":"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.TraceID, "0af7651916cd43dd8448eb211c80319c"; got != want { t.Errorf("TraceID = %q, want %q", got, want) } }) t.Run("GIVEN OTel JSON with trace.id WHEN parsed THEN TraceID is extracted", func(t *testing.T) { t.Parallel() raw := `{"timestamp":"2026-05-09T14:32:01Z","severity":"ERROR","message":"something broke","trace.id":"deadbeefcafe1234","span.id":"helper0"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.TraceID, "deadbeefcafe1234"; got != want { t.Errorf("TraceID = %q, want %q", got, want) } if got, want := entry.SpanID, "helper0"; got != want { t.Errorf("SpanID = %q, want %q", got, want) } if got, want := entry.Level, "ERROR"; got != want { t.Errorf("Level = %q, want %q", got, want) } if got, want := entry.Message, "something broke"; got != want { t.Errorf("Message = %q, want %q", got, want) } }) t.Run("GIVEN non-JSON input WHEN JSONParser parses THEN zero LogEntry returned", func(t *testing.T) { t.Parallel() raw := `this is not json at all` p := parser.JSONParser{} entry, ok := p.Parse(raw) if ok { t.Error("ok = true, want false") } if entry.Raw != "" { t.Errorf("Raw = %q, want empty (zero LogEntry)", entry.Raw) } if entry.TraceID != "" { t.Errorf("TraceID = %q, want empty", entry.TraceID) } }) t.Run("GIVEN JSON array WHEN JSONParser parses THEN zero LogEntry returned", func(t *testing.T) { t.Parallel() raw := `[1, 2, 3]` p := parser.JSONParser{} entry, ok := p.Parse(raw) if ok { t.Error("ok = true, want false (only objects parse)") } if entry.Raw != "" { t.Errorf("Raw = %q, want empty (zero LogEntry)", entry.Raw) } }) t.Run("GIVEN JSON without trace ID WHEN parsed THEN TraceID is empty and all fields go to Fields", func(t *testing.T) { t.Parallel() raw := `{"time":"2026-05-09T14:32:01Z","level":"DEBUG","msg":"health check","status":"ok"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if entry.TraceID != "" { t.Errorf("TraceID = %q, want empty", entry.TraceID) } if got, want := entry.Message, "health check"; got != want { t.Errorf("Message = %q, want %q", got, want) } if status, ok := entry.Fields["status"]; !ok || status != "ok" { t.Errorf("Fields[status] = %v, want 'ok'", status) } }) t.Run("GIVEN JSON with @timestamp WHEN parsed THEN timestamp is extracted", func(t *testing.T) { t.Parallel() raw := `{"@timestamp":"2026-05-09T14:32:01.000Z","level":"WARN","msg":"slow request"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } expectedTime := time.Date(2026, 5, 9, 14, 32, 1, 0, time.UTC) if !entry.Timestamp.Equal(expectedTime) { t.Errorf("Timestamp = %v, want %v", entry.Timestamp, expectedTime) } }) t.Run("GIVEN JSON with unknown trace_id field type WHEN parsed THEN TraceID is empty", func(t *testing.T) { t.Parallel() raw := `{"time":"2026-05-09T14:32:01Z","level":"INFO","trace_id":12345}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if entry.TraceID != "" { t.Errorf("TraceID = %q, want empty (non-string trace_id)", entry.TraceID) } }) t.Run("GIVEN empty JSON object WHEN parsed THEN entry has Raw and Fields but empty typed fields", func(t *testing.T) { t.Parallel() raw := `{}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.Raw, raw; got != want { t.Errorf("Raw = %q, want %q", got, want) } if entry.TraceID != "" { t.Errorf("TraceID = %q, want empty", entry.TraceID) } if entry.Level != "" { t.Errorf("Level = %q, want empty", entry.Level) } if entry.Message != "" { t.Errorf("Message = %q, want empty", entry.Message) } if entry.Timestamp.IsZero() { t.Error("Timestamp is zero, want non-zero (fallback to time.Now)") } }) t.Run("GIVEN trace_id that resembles traceparent but has wrong length WHEN parsed THEN value is returned as-is", func(t *testing.T) { t.Parallel() raw := `{"trace_id":"01-deadbeefcafe1234567890abcdefxxxx-yyyy"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.TraceID, "01-deadbeefcafe1234567890abcdefxxxx-yyyy"; got != want { t.Errorf("TraceID = %q, want %q (non-W3C-shaped value passed through unchanged)", got, want) } }) t.Run("GIVEN traceparent with wrong dash positions WHEN parsed THEN value is not split", func(t *testing.T) { t.Parallel() raw := `{"traceparent":"00x0af7651916cd43dd8448eb211c80319cxb7ad6b7169203331x01"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if got, want := entry.TraceID, "00x0af7651916cd43dd8448eb211c80319cxb7ad6b7169203331x01"; got != want { t.Errorf("TraceID = %q, want passthrough %q (no dashes at expected positions)", got, want) } }) t.Run("GIVEN numeric epoch seconds WHEN parsed THEN timestamp converted from Unix seconds", func(t *testing.T) { t.Parallel() raw := `{"time":1715260321,"msg":"hi"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } expected := time.Unix(1715260321, 0) if !entry.Timestamp.Equal(expected) { t.Errorf("Timestamp = %v, want %v", entry.Timestamp, expected) } }) t.Run("GIVEN numeric epoch milliseconds WHEN parsed THEN timestamp converted from Unix milliseconds", func(t *testing.T) { t.Parallel() raw := `{"time":1715260321088,"msg":"hi"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } expected := time.Unix(0, 1715260321088*int64(time.Millisecond)) if !entry.Timestamp.Equal(expected) { t.Errorf("Timestamp = %v, want %v", entry.Timestamp, expected) } }) t.Run("GIVEN fractional epoch seconds WHEN parsed THEN sub-second precision preserved", func(t *testing.T) { t.Parallel() raw := `{"time":1715260321.5,"msg":"hi"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } expectedSecs := int64(1715260321) if entry.Timestamp.Unix() != expectedSecs { t.Errorf("Timestamp.Unix() = %d, want %d", entry.Timestamp.Unix(), expectedSecs) } if entry.Timestamp.Nanosecond() == 0 { t.Error("Nanosecond = 0, want non-zero (fractional part should be preserved)") } }) t.Run("GIVEN numeric time WHEN parsed THEN time key does not leak into Fields", func(t *testing.T) { t.Parallel() raw := `{"time":1715260321,"msg":"hi","keep":"yes"}` p := parser.JSONParser{} entry, ok := p.Parse(raw) if !ok { t.Fatal("ok = false, want true") } if _, exists := entry.Fields["time"]; exists { t.Error("Fields contains 'time' key, want it removed") } if _, exists := entry.Fields["keep"]; !exists { t.Error("Fields missing 'keep' key, want preserved") } }) }