diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index b0b3bc2..ed792ae 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -8,7 +8,7 @@ ## Принципы импорта -1. **Snapshot данных** - JSON содержит состояние сервера на момент сбора, без исторической информации +1. **Snapshot данных** - JSON содержит состояние сервера на момент сбора и может включать историю изменений статуса компонентов 2. **Автоматическое определение LOT** - классификация компонентов определяется приложением на основе vendor/model/type 3. **Статус компонентов** - каждый компонент имеет статус работоспособности (OK, Warning, Critical, Unknown) и может передавать время проверки статуса 4. **Идемпотентность** - повторный импорт с тем же snapshot не создает дубликаты @@ -58,8 +58,24 @@ Для секций `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies` поддерживается дополнительное поле: - `status_checked_at` (string RFC3339, опционально) - дата/время, когда был проверен статус работоспособности компонента +- `status_changed_at` (string RFC3339, опционально) - дата/время последнего изменения статуса компонента +- `status_history` (array, опционально) - история статусов компонента: + - `status` (string) - статус (`OK`, `Warning`, `Critical`, `Unknown`, `Empty`) + - `changed_at` (string RFC3339) - дата/время смены статуса + - `details` (string, опционально) - пояснение к переходу статуса - `error_description` (string, опционально) - текст ошибки/диагностики для статуса компонента (например при `Warning`/`Critical`) +### Правила экспорта JSON для внешнего проекта + +Используйте эти правила, если JSON формируется внешним сервисом/экспортером: + +1. Всегда передавайте `status` как текущее состояние компонента в snapshot. +2. Если есть точное время последней смены, передавайте `status_changed_at` (RFC3339, UTC). +3. Если источник хранит историю (например Windows Event Log), передавайте `status_history` отсортированным по `changed_at` по возрастанию. +4. В `status_history` не отправляйте записи без `changed_at`; такие записи игнорируются. +5. Для совместимости допускается передавать только старые поля (`status`, `status_checked_at`) без истории. +6. Все даты/время в исторических полях должны быть RFC3339; рекомендуется использовать UTC (`Z`). + --- ## Секция hardware @@ -889,7 +905,7 @@ Content-Type: application/json } ``` -### Пример 2: Server с отказавшим диском +### Пример 2: Server с историей "сломан -> починен" ```json { @@ -910,8 +926,20 @@ Content-Type: application/json "firmware": "9CV10510", "interface": "NVMe", "present": true, - "status": "Critical", - "error_description": "Error Code on GPU 0 [18:00.0] (S/N 1653925025827) = 020000190097 (unexpected device interrupts)" + "status": "OK", + "status_changed_at": "2026-02-10T15:22:00Z", + "status_history": [ + { + "status": "Critical", + "changed_at": "2026-02-10T15:10:00Z", + "details": "I/O timeout on NVMe queue 3" + }, + { + "status": "OK", + "changed_at": "2026-02-10T15:22:00Z", + "details": "Recovered after controller reset" + } + ] }, { "slot": "Disk.Bay.1", @@ -929,9 +957,9 @@ Content-Type: application/json ``` **Обработка:** -- Disk.Bay.0 получит статус Critical -- Автоматически создастся failure_event для компонента S5GUNG0N123456 -- Timeline event COMPONENT_FAILED +- Disk.Bay.0 получит текущий статус `OK` +- История статусов сохранится в `observations.details.status_history` +- Автоматический `failure_event` не создается, так как текущий статус snapshot не `Critical` ### Пример 3: Замена памяти diff --git a/docs/import-example-full.json b/docs/import-example-full.json index e067fbe..d6bd480 100644 --- a/docs/import-example-full.json +++ b/docs/import-example-full.json @@ -278,8 +278,20 @@ "firmware": "SN02", "interface": "SATA", "present": true, - "status": "Critical", - "error_description": "Error Code on GPU 0 [18:00.0] (S/N 1653925025827) = 020000190097 (unexpected device interrupts)" + "status": "OK", + "status_changed_at": "2026-02-10T15:22:00Z", + "status_history": [ + { + "status": "Critical", + "changed_at": "2026-02-10T15:10:00Z", + "details": "I/O timeout on NVMe queue 3" + }, + { + "status": "OK", + "changed_at": "2026-02-10T15:22:00Z", + "details": "Recovered after controller reset" + } + ] } ], "pcie_devices": [ diff --git a/internal/api/import-example-full.json b/internal/api/import-example-full.json index e067fbe..d6bd480 100644 --- a/internal/api/import-example-full.json +++ b/internal/api/import-example-full.json @@ -278,8 +278,20 @@ "firmware": "SN02", "interface": "SATA", "present": true, - "status": "Critical", - "error_description": "Error Code on GPU 0 [18:00.0] (S/N 1653925025827) = 020000190097 (unexpected device interrupts)" + "status": "OK", + "status_changed_at": "2026-02-10T15:22:00Z", + "status_history": [ + { + "status": "Critical", + "changed_at": "2026-02-10T15:10:00Z", + "details": "I/O timeout on NVMe queue 3" + }, + { + "status": "OK", + "changed_at": "2026-02-10T15:22:00Z", + "details": "Recovered after controller reset" + } + ] } ], "pcie_devices": [ diff --git a/internal/api/ingest_hardware_test.go b/internal/api/ingest_hardware_test.go index 37ddab7..f70ebd2 100644 --- a/internal/api/ingest_hardware_test.go +++ b/internal/api/ingest_hardware_test.go @@ -287,3 +287,88 @@ func TestIngestHardwareCreatesMachineInStock(t *testing.T) { t.Fatalf("expected stock machine with null project, got project=%v", projectID.String) } } + +func TestIngestHardwareStoresStatusHistoryInObservationDetails(t *testing.T) { + dsn := os.Getenv("DATABASE_DSN") + if dsn == "" { + t.Skip("DATABASE_DSN not set") + } + + db, err := repository.Open(dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + if err := applyMigrations(db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + if err := cleanupRegistry(db); err != nil { + t.Fatalf("cleanup: %v", err) + } + + mux := http.NewServeMux() + RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)}) + server := httptest.NewServer(mux) + defer server.Close() + + payload := map[string]any{ + "target_host": "history-server", + "collected_at": "2026-02-10T15:30:00Z", + "hardware": map[string]any{ + "board": map[string]any{"serial_number": "HISTORY-001"}, + "storage": []map[string]any{ + { + "slot": "Disk.Bay.0", + "serial_number": "HISTORY-DISK-001", + "present": true, + "status": "OK", + "status_changed_at": "2026-02-10T15:22:00Z", + "status_history": []map[string]any{ + { + "status": "Critical", + "changed_at": "2026-02-10T15:10:00Z", + }, + { + "status": "OK", + "changed_at": "2026-02-10T15:22:00Z", + "details": "Recovered after controller reset", + }, + }, + }, + }, + }, + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + resp, err := http.Post(server.URL+"/ingest/hardware", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var historyStatus, statusChangedAt string + row := db.QueryRow(` + SELECT + JSON_UNQUOTE(JSON_EXTRACT(details, '$.status_history[0].status')), + JSON_UNQUOTE(JSON_EXTRACT(details, '$.status_changed_at')) + FROM observations + ORDER BY observed_at DESC, id DESC + LIMIT 1 + `) + if err := row.Scan(&historyStatus, &statusChangedAt); err != nil { + t.Fatalf("details query: %v", err) + } + if historyStatus != "CRITICAL" { + t.Fatalf("expected first status_history status CRITICAL, got %q", historyStatus) + } + if statusChangedAt != "2026-02-10T15:22:00Z" { + t.Fatalf("expected status_changed_at=2026-02-10T15:22:00Z, got %q", statusChangedAt) + } +} diff --git a/internal/ingest/parser_hardware.go b/internal/ingest/parser_hardware.go index ef5c7b0..9409c88 100644 --- a/internal/ingest/parser_hardware.go +++ b/internal/ingest/parser_hardware.go @@ -46,103 +46,121 @@ type HardwareFirmwareRecord struct { } type HardwareCPU struct { - Socket *int `json:"socket"` - Model *string `json:"model"` - Manufacturer *string `json:"manufacturer"` - Status *string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at"` - ErrorDescription *string `json:"error_description"` - Present *bool `json:"present"` - SerialNumber *string `json:"serial_number"` - Firmware *string `json:"firmware"` - Cores *int `json:"cores"` - Threads *int `json:"threads"` - FrequencyMHz *int `json:"frequency_mhz"` - MaxFrequencyMHz *int `json:"max_frequency_mhz"` + Socket *int `json:"socket"` + Model *string `json:"model"` + Manufacturer *string `json:"manufacturer"` + Status *string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at"` + StatusChangedAt *string `json:"status_changed_at"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description"` + Present *bool `json:"present"` + SerialNumber *string `json:"serial_number"` + Firmware *string `json:"firmware"` + Cores *int `json:"cores"` + Threads *int `json:"threads"` + FrequencyMHz *int `json:"frequency_mhz"` + MaxFrequencyMHz *int `json:"max_frequency_mhz"` } type HardwareMemory struct { - Slot *string `json:"slot"` - Location *string `json:"location"` - Present *bool `json:"present"` - SizeMB *int `json:"size_mb"` - Type *string `json:"type"` - MaxSpeedMHz *int `json:"max_speed_mhz"` - CurrentSpeedMHz *int `json:"current_speed_mhz"` - Manufacturer *string `json:"manufacturer"` - SerialNumber *string `json:"serial_number"` - PartNumber *string `json:"part_number"` - Status *string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at"` - ErrorDescription *string `json:"error_description"` + Slot *string `json:"slot"` + Location *string `json:"location"` + Present *bool `json:"present"` + SizeMB *int `json:"size_mb"` + Type *string `json:"type"` + MaxSpeedMHz *int `json:"max_speed_mhz"` + CurrentSpeedMHz *int `json:"current_speed_mhz"` + Manufacturer *string `json:"manufacturer"` + SerialNumber *string `json:"serial_number"` + PartNumber *string `json:"part_number"` + Status *string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at"` + StatusChangedAt *string `json:"status_changed_at"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description"` } type HardwareStorage struct { - Slot *string `json:"slot"` - Type *string `json:"type"` - Model *string `json:"model"` - SizeGB *int `json:"size_gb"` - SerialNumber *string `json:"serial_number"` - Manufacturer *string `json:"manufacturer"` - Firmware *string `json:"firmware"` - Interface *string `json:"interface"` - Present *bool `json:"present"` - Status *string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at"` - ErrorDescription *string `json:"error_description"` + Slot *string `json:"slot"` + Type *string `json:"type"` + Model *string `json:"model"` + SizeGB *int `json:"size_gb"` + SerialNumber *string `json:"serial_number"` + Manufacturer *string `json:"manufacturer"` + Firmware *string `json:"firmware"` + Interface *string `json:"interface"` + Present *bool `json:"present"` + Status *string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at"` + StatusChangedAt *string `json:"status_changed_at"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description"` } type HardwarePCIeDevice struct { - Slot *string `json:"slot"` - VendorID *int `json:"vendor_id"` - DeviceID *int `json:"device_id"` - BDF *string `json:"bdf"` - DeviceClass *string `json:"device_class"` - Manufacturer *string `json:"manufacturer"` - Model *string `json:"model"` - LinkWidth *int `json:"link_width"` - LinkSpeed *string `json:"link_speed"` - MaxLinkWidth *int `json:"max_link_width"` - MaxLinkSpeed *string `json:"max_link_speed"` - SerialNumber *string `json:"serial_number"` - Firmware *string `json:"firmware"` - Present *bool `json:"present"` - Status *string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at"` - ErrorDescription *string `json:"error_description"` + Slot *string `json:"slot"` + VendorID *int `json:"vendor_id"` + DeviceID *int `json:"device_id"` + BDF *string `json:"bdf"` + DeviceClass *string `json:"device_class"` + Manufacturer *string `json:"manufacturer"` + Model *string `json:"model"` + LinkWidth *int `json:"link_width"` + LinkSpeed *string `json:"link_speed"` + MaxLinkWidth *int `json:"max_link_width"` + MaxLinkSpeed *string `json:"max_link_speed"` + SerialNumber *string `json:"serial_number"` + Firmware *string `json:"firmware"` + Present *bool `json:"present"` + Status *string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at"` + StatusChangedAt *string `json:"status_changed_at"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description"` } type HardwarePowerSupply struct { - Slot *string `json:"slot"` - Present *bool `json:"present"` - Model *string `json:"model"` - Vendor *string `json:"vendor"` - WattageW *int `json:"wattage_w"` - SerialNumber *string `json:"serial_number"` - PartNumber *string `json:"part_number"` - Firmware *string `json:"firmware"` - Status *string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at"` - ErrorDescription *string `json:"error_description"` - InputType *string `json:"input_type"` - InputPowerW *float64 `json:"input_power_w"` - OutputPowerW *float64 `json:"output_power_w"` - InputVoltage *float64 `json:"input_voltage"` + Slot *string `json:"slot"` + Present *bool `json:"present"` + Model *string `json:"model"` + Vendor *string `json:"vendor"` + WattageW *int `json:"wattage_w"` + SerialNumber *string `json:"serial_number"` + PartNumber *string `json:"part_number"` + Firmware *string `json:"firmware"` + Status *string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at"` + StatusChangedAt *string `json:"status_changed_at"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description"` + InputType *string `json:"input_type"` + InputPowerW *float64 `json:"input_power_w"` + OutputPowerW *float64 `json:"output_power_w"` + InputVoltage *float64 `json:"input_voltage"` +} + +type HardwareStatusHistoryEntry struct { + Status string `json:"status"` + ChangedAt string `json:"changed_at"` + Details *string `json:"details,omitempty"` } type HardwareComponent struct { - ComponentType string `json:"component_type"` - VendorSerial string `json:"vendor_serial"` - Vendor *string `json:"vendor,omitempty"` - Model *string `json:"model,omitempty"` - Firmware *string `json:"firmware,omitempty"` - Status string `json:"status"` - StatusCheckedAt *string `json:"status_checked_at,omitempty"` - ErrorDescription *string `json:"error_description,omitempty"` - Present *bool `json:"present,omitempty"` - Slot *string `json:"slot,omitempty"` - Attributes map[string]any `json:"attributes,omitempty"` - Telemetry map[string]any `json:"telemetry,omitempty"` + ComponentType string `json:"component_type"` + VendorSerial string `json:"vendor_serial"` + Vendor *string `json:"vendor,omitempty"` + Model *string `json:"model,omitempty"` + Firmware *string `json:"firmware,omitempty"` + Status string `json:"status"` + StatusCheckedAt *string `json:"status_checked_at,omitempty"` + StatusChangedAt *string `json:"status_changed_at,omitempty"` + StatusHistory []HardwareStatusHistoryEntry `json:"status_history,omitempty"` + ErrorDescription *string `json:"error_description,omitempty"` + Present *bool `json:"present,omitempty"` + Slot *string `json:"slot,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` + Telemetry map[string]any `json:"telemetry,omitempty"` } func FlattenHardwareComponents(snapshot HardwareSnapshot) ([]HardwareComponent, []HardwareFirmwareRecord) { @@ -198,6 +216,8 @@ func flattenCPUs(boardSerial string, items []HardwareCPU) []HardwareComponent { Firmware: normalizeString(item.Firmware), Status: status, StatusCheckedAt: normalizeString(item.StatusCheckedAt), + StatusChangedAt: normalizeString(item.StatusChangedAt), + StatusHistory: normalizeStatusHistory(item.StatusHistory), ErrorDescription: normalizeString(item.ErrorDescription), Present: boolPtr(present), Attributes: structToMap(item), @@ -228,6 +248,8 @@ func flattenMemory(boardSerial string, items []HardwareMemory) []HardwareCompone Firmware: nil, Status: status, StatusCheckedAt: normalizeString(item.StatusCheckedAt), + StatusChangedAt: normalizeString(item.StatusChangedAt), + StatusHistory: normalizeStatusHistory(item.StatusHistory), ErrorDescription: normalizeString(item.ErrorDescription), Present: boolPtr(present), Slot: normalizeString(item.Slot), @@ -259,6 +281,8 @@ func flattenStorage(boardSerial string, items []HardwareStorage) []HardwareCompo Firmware: normalizeString(item.Firmware), Status: status, StatusCheckedAt: normalizeString(item.StatusCheckedAt), + StatusChangedAt: normalizeString(item.StatusChangedAt), + StatusHistory: normalizeStatusHistory(item.StatusHistory), ErrorDescription: normalizeString(item.ErrorDescription), Present: boolPtr(present), Slot: normalizeString(item.Slot), @@ -293,6 +317,8 @@ func flattenPCIe(boardSerial string, items []HardwarePCIeDevice) []HardwareCompo Firmware: normalizeString(item.Firmware), Status: status, StatusCheckedAt: normalizeString(item.StatusCheckedAt), + StatusChangedAt: normalizeString(item.StatusChangedAt), + StatusHistory: normalizeStatusHistory(item.StatusHistory), ErrorDescription: normalizeString(item.ErrorDescription), Present: boolPtr(present), Slot: normalizeString(item.Slot), @@ -324,6 +350,8 @@ func flattenPSUs(boardSerial string, items []HardwarePowerSupply) []HardwareComp Firmware: normalizeString(item.Firmware), Status: status, StatusCheckedAt: normalizeString(item.StatusCheckedAt), + StatusChangedAt: normalizeString(item.StatusChangedAt), + StatusHistory: normalizeStatusHistory(item.StatusHistory), ErrorDescription: normalizeString(item.ErrorDescription), Present: boolPtr(present), Slot: normalizeString(item.Slot), @@ -386,6 +414,29 @@ func normalizeStatus(value *string) string { return normalized } +func normalizeStatusHistory(entries []HardwareStatusHistoryEntry) []HardwareStatusHistoryEntry { + if len(entries) == 0 { + return nil + } + result := make([]HardwareStatusHistoryEntry, 0, len(entries)) + for _, entry := range entries { + status := normalizeStatus(&entry.Status) + changedAt := strings.TrimSpace(entry.ChangedAt) + if changedAt == "" { + continue + } + result = append(result, HardwareStatusHistoryEntry{ + Status: status, + ChangedAt: changedAt, + Details: normalizeString(entry.Details), + }) + } + if len(result) == 0 { + return nil + } + return result +} + func boolPtr(value bool) *bool { return &value } diff --git a/internal/ingest/service.go b/internal/ingest/service.go index 4ad0426..0102d43 100644 --- a/internal/ingest/service.go +++ b/internal/ingest/service.go @@ -824,6 +824,28 @@ func observationDetailsPayload(component HardwareComponent) ([]byte, error) { if component.StatusCheckedAt != nil && strings.TrimSpace(*component.StatusCheckedAt) != "" { detail["status_checked_at"] = *component.StatusCheckedAt } + if component.StatusChangedAt != nil && strings.TrimSpace(*component.StatusChangedAt) != "" { + detail["status_changed_at"] = *component.StatusChangedAt + } + if len(component.StatusHistory) > 0 { + history := make([]map[string]any, 0, len(component.StatusHistory)) + for _, item := range component.StatusHistory { + if strings.TrimSpace(item.ChangedAt) == "" { + continue + } + entry := map[string]any{ + "status": item.Status, + "changed_at": item.ChangedAt, + } + if item.Details != nil && strings.TrimSpace(*item.Details) != "" { + entry["details"] = *item.Details + } + history = append(history, entry) + } + if len(history) > 0 { + detail["status_history"] = history + } + } if component.ErrorDescription != nil && strings.TrimSpace(*component.ErrorDescription) != "" { detail["error_description"] = *component.ErrorDescription } @@ -851,6 +873,28 @@ func (s *Service) upsertHardwareFailureEvent(ctx context.Context, tx *sql.Tx, as if component.StatusCheckedAt != nil && strings.TrimSpace(*component.StatusCheckedAt) != "" { details["status_checked_at"] = *component.StatusCheckedAt } + if component.StatusChangedAt != nil && strings.TrimSpace(*component.StatusChangedAt) != "" { + details["status_changed_at"] = *component.StatusChangedAt + } + if len(component.StatusHistory) > 0 { + history := make([]map[string]any, 0, len(component.StatusHistory)) + for _, item := range component.StatusHistory { + if strings.TrimSpace(item.ChangedAt) == "" { + continue + } + entry := map[string]any{ + "status": item.Status, + "changed_at": item.ChangedAt, + } + if item.Details != nil && strings.TrimSpace(*item.Details) != "" { + entry["details"] = *item.Details + } + history = append(history, entry) + } + if len(history) > 0 { + details["status_history"] = history + } + } if component.ErrorDescription != nil && strings.TrimSpace(*component.ErrorDescription) != "" { details["error_description"] = *component.ErrorDescription } diff --git a/internal/ingest/service_observation_test.go b/internal/ingest/service_observation_test.go index 327791f..5911cc1 100644 --- a/internal/ingest/service_observation_test.go +++ b/internal/ingest/service_observation_test.go @@ -7,11 +7,18 @@ import ( func TestObservationDetailsPayloadIncludesStatusCheckedAt(t *testing.T) { statusCheckedAt := "2026-02-10T15:28:00Z" + statusChangedAt := "2026-02-10T15:20:00Z" errorDescription := "Error Code on GPU 0 [18:00.0] (S/N 1653925025827) = 020000190097 (unexpected device interrupts)" + historyDetails := "auto-recovered after reboot" component := HardwareComponent{ - ComponentType: "storage", - Status: "CRITICAL", - StatusCheckedAt: &statusCheckedAt, + ComponentType: "storage", + Status: "CRITICAL", + StatusCheckedAt: &statusCheckedAt, + StatusChangedAt: &statusChangedAt, + StatusHistory: []HardwareStatusHistoryEntry{ + {Status: "CRITICAL", ChangedAt: "2026-02-10T15:20:00Z"}, + {Status: "OK", ChangedAt: "2026-02-10T15:25:00Z", Details: &historyDetails}, + }, ErrorDescription: &errorDescription, } @@ -33,6 +40,22 @@ func TestObservationDetailsPayloadIncludesStatusCheckedAt(t *testing.T) { t.Fatalf("status_checked_at = %v, want %v", got, statusCheckedAt) } + gotChangedAt, ok := details["status_changed_at"] + if !ok { + t.Fatalf("status_changed_at is missing in details payload") + } + if gotChangedAt != statusChangedAt { + t.Fatalf("status_changed_at = %v, want %v", gotChangedAt, statusChangedAt) + } + + historyRaw, ok := details["status_history"].([]any) + if !ok { + t.Fatalf("status_history is missing in details payload") + } + if len(historyRaw) != 2 { + t.Fatalf("status_history len = %d, want 2", len(historyRaw)) + } + gotErr, ok := details["error_description"] if !ok { t.Fatalf("error_description is missing in details payload")