diff --git a/src/App.php b/src/App.php index abdef4e..d414355 100644 --- a/src/App.php +++ b/src/App.php @@ -266,10 +266,14 @@ final class App } $username = (string) ($user['username'] ?? ''); - $payload = request_json_body(); + $rawBody = (string) file_get_contents('php://input'); + $payload = $this->decodeHealthImportPayload($rawBody); if ($payload === []) { - $this->users->recordHealthImport($username, 'error', 'Leerer oder ungültiger JSON-Import.'); - json_response(['ok' => false, 'message' => 'Leerer oder ungültiger JSON-Import.'], 400); + $traceID = $this->healthImportTraceID(); + $message = 'Diagnose-ID: ' . $traceID . '. Leerer oder ungültiger JSON-Import.'; + $this->logHealthImportFailure($traceID, $username, 'Leerer oder ungültiger JSON-Import.', [], strlen($rawBody)); + $this->users->recordHealthImport($username, 'error', $message); + json_response(['ok' => false, 'message' => $message], 400); } try { @@ -285,11 +289,55 @@ final class App $this->users->recordHealthImport($username, 'ok', $message); json_response(['ok' => true, 'message' => $message, 'result' => $result]); } catch (RuntimeException $exception) { - $this->users->recordHealthImport($username, 'error', $exception->getMessage()); - json_response(['ok' => false, 'message' => $exception->getMessage()], 400); + $traceID = $this->healthImportTraceID(); + $message = 'Diagnose-ID: ' . $traceID . '. ' . $exception->getMessage(); + $this->logHealthImportFailure($traceID, $username, $exception->getMessage(), $payload, strlen($rawBody)); + $this->users->recordHealthImport($username, 'error', $message); + json_response(['ok' => false, 'message' => $message], 400); } } + private function decodeHealthImportPayload(string $rawBody): array + { + if (trim($rawBody) === '') { + return []; + } + + $decoded = json_decode($rawBody, true); + + return is_array($decoded) ? $decoded : []; + } + + private function healthImportTraceID(): string + { + try { + return substr(bin2hex(random_bytes(4)), 0, 8); + } catch (Exception) { + return substr(sha1((string) microtime(true)), 0, 8); + } + } + + private function logHealthImportFailure(string $traceID, string $username, string $message, array $payload, int $rawLength): void + { + $metrics = $payload === [] ? [] : $this->healthMetricsFromPayload($payload); + $workouts = $payload === [] ? [] : $this->healthWorkoutsFromPayload($payload); + $context = [ + 'trace_id' => $traceID, + 'user' => $username, + 'message' => $message, + 'method' => (string) ($_SERVER['REQUEST_METHOD'] ?? ''), + 'path' => (string) ($_SERVER['REQUEST_URI'] ?? ''), + 'content_type' => (string) ($_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? '')), + 'content_length' => (string) ($_SERVER['CONTENT_LENGTH'] ?? ''), + 'raw_length' => $rawLength, + 'json_error' => json_last_error_msg(), + 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), + 'payload' => $this->healthPayloadDiagnostics($payload, $metrics, $workouts), + ]; + + error_log('[Mood Health Import] ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + private function handleHealthImportStatus(): void { $user = $this->requireUser(); @@ -569,6 +617,106 @@ final class App return implode('. ', $parts) . '.'; } + private function healthPayloadDiagnostics(array $payload, array $metrics, array $workouts): array + { + return [ + 'top_level' => $this->describeHealthPayloadNode($payload), + 'metrics_count' => count($metrics), + 'workouts_count' => count($workouts), + 'metric_samples' => $this->healthMetricDiagnostics($metrics), + 'workout_samples' => $this->healthArraySamples($workouts), + 'payload_samples' => $this->healthNestedArraySamples($payload), + ]; + } + + private function healthMetricDiagnostics(array $metrics): array + { + $samples = []; + foreach (array_slice($metrics, 0, 8) as $index => $metric) { + if (!is_array($metric)) { + continue; + } + + $data = is_array($metric['data'] ?? null) ? $metric['data'] : []; + $firstPoint = null; + foreach ($data as $point) { + if (is_array($point)) { + $firstPoint = $point; + break; + } + } + + $samples[] = [ + 'index' => $index, + 'name' => (string) ($metric['name'] ?? ''), + 'normalized' => $this->healthMetricName((string) ($metric['name'] ?? '')), + 'data_count' => count($data), + 'keys' => array_slice(array_keys($metric), 0, 12), + 'first_point' => is_array($firstPoint) ? $this->describeHealthPayloadNode($firstPoint) : null, + ]; + } + + return $samples; + } + + private function healthArraySamples(array $items): array + { + $samples = []; + foreach (array_slice($items, 0, 5) as $index => $item) { + if (is_array($item)) { + $samples[] = ['index' => $index] + $this->describeHealthPayloadNode($item); + } + } + + return $samples; + } + + private function healthNestedArraySamples(array $payload, string $path = '$', int $depth = 0): array + { + if ($depth > 2) { + return []; + } + + $samples = []; + foreach ($payload as $key => $value) { + $currentPath = $path . (is_int($key) ? '[' . $key . ']' : '.' . (string) $key); + if (!is_array($value)) { + continue; + } + + $samples[] = ['path' => $currentPath] + $this->describeHealthPayloadNode($value); + if (count($samples) >= 12) { + break; + } + + foreach ($this->healthNestedArraySamples($value, $currentPath, $depth + 1) as $nested) { + $samples[] = $nested; + if (count($samples) >= 12) { + break 2; + } + } + } + + return $samples; + } + + private function describeHealthPayloadNode(array $node): array + { + $keys = array_slice(array_keys($node), 0, 12); + $types = []; + foreach ($keys as $key) { + $value = $node[$key] ?? null; + $types[(string) $key] = is_array($value) ? 'array(' . count($value) . ')' : get_debug_type($value); + } + + return [ + 'is_list' => array_is_list($node), + 'count' => count($node), + 'keys' => $keys, + 'types' => $types, + ]; + } + private function healthEventsFromMetrics(array $metrics): array { $steps = [];