diff --git a/apps/website/README.md b/apps/website/README.md index 1fa3a4c..21199b3 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -55,6 +55,25 @@ The OCI edge nginx layer handles gzip, cache HITs, HTTP/2, TLS session reuse, client keepalive, and upstream keepalive to the Traefik backend. CSS and JavaScript keep the edge's longer immutable asset policy. +## Translation Observability + +`translate.php` emits one structured JSON log event for each valid translation +request. The event name is `website_translation` and the payload does not include +source text or translated text. + +Useful fields: + +- `cache_hits` +- `cache_misses` +- `ollama_latency_ms` +- `failed_json_parse_count` +- `timeout_count` +- `status` +- `reason` + +In Loki, filter website logs for `"event":"website_translation"` to inspect +cache behavior, Ollama latency, JSON parse failures, and timeout failures. + ## Validation Do not build images locally. The image is built and deployed by the Debian diff --git a/apps/website/translate.php b/apps/website/translate.php index dcf1323..c1e710e 100644 --- a/apps/website/translate.php +++ b/apps/website/translate.php @@ -2,6 +2,8 @@ header('Content-Type: application/json'); +$requestStartedAt = microtime(true); + function translate_response(int $status, array $body): never { http_response_code($status); echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); @@ -18,6 +20,22 @@ function clean_translate_text(string $value, int $maxLength = 4000): string { return $value; } +function elapsed_ms(float $startedAt): int { + return (int) round((microtime(true) - $startedAt) * 1000); +} + +function translation_log(array $fields): void { + $payload = array_merge([ + 'event' => 'website_translation', + 'timestamp' => gmdate('c'), + ], $fields); + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json !== false) { + error_log($json); + } +} + function redis_encode_command(array $parts): string { $command = '*' . count($parts) . "\r\n"; foreach ($parts as $part) { @@ -215,14 +233,32 @@ foreach ($texts as $index => $text) { $missTexts[] = $text; } +$cacheHits = count($texts) - count($missTexts); +$cacheMisses = count($missTexts); +$failedJsonParseCount = 0; +$ollamaTimeoutCount = 0; +$ollamaLatencyMs = null; + if (!$missTexts) { ksort($cleanTranslations); + translation_log([ + 'status' => 'success', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'duration_ms' => elapsed_ms($requestStartedAt), + ]); echo json_encode([ 'translations' => array_values($cleanTranslations), 'model' => $ollamaModel, 'cache' => [ - 'hits' => count($texts), - 'misses' => 0, + 'hits' => $cacheHits, + 'misses' => $cacheMisses, ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; @@ -254,28 +290,95 @@ curl_setopt_array($curl, [ CURLOPT_TIMEOUT => 130, ]); +$ollamaStartedAt = microtime(true); $rawResponse = curl_exec($curl); $curlError = curl_error($curl); +$curlErrno = curl_errno($curl); $statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); +$ollamaLatencyMs = elapsed_ms($ollamaStartedAt); curl_close($curl); if ($rawResponse === false || $statusCode < 200 || $statusCode >= 300) { + $ollamaTimeoutCount = $curlErrno === CURLE_OPERATION_TIMEDOUT ? 1 : 0; + translation_log([ + 'status' => 'error', + 'reason' => 'ollama_request_failed', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'ollama_status_code' => $statusCode, + 'curl_errno' => $curlErrno, + 'duration_ms' => elapsed_ms($requestStartedAt), + ]); translate_response(502, ['error' => 'Ollama request failed', 'status' => $statusCode, 'detail' => $curlError]); } $ollamaResponse = json_decode($rawResponse, true); if (!is_array($ollamaResponse) || !isset($ollamaResponse['response']) || !is_string($ollamaResponse['response'])) { + if (json_last_error() !== JSON_ERROR_NONE) { + $failedJsonParseCount++; + } + translation_log([ + 'status' => 'error', + 'reason' => 'invalid_ollama_response', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'ollama_status_code' => $statusCode, + 'duration_ms' => elapsed_ms($requestStartedAt), + ]); translate_response(502, ['error' => 'Invalid Ollama response']); } $translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response'])); $translations = json_decode($translationJson, true); if (!is_array($translations) || count($translations) !== count($missTexts)) { + if (json_last_error() !== JSON_ERROR_NONE) { + $failedJsonParseCount++; + } + translation_log([ + 'status' => 'error', + 'reason' => 'unexpected_translation_response', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'ollama_status_code' => $statusCode, + 'duration_ms' => elapsed_ms($requestStartedAt), + ]); translate_response(502, ['error' => 'Unexpected translation response']); } foreach ($translations as $index => $translation) { if (!is_string($translation) && !is_numeric($translation)) { + translation_log([ + 'status' => 'error', + 'reason' => 'unexpected_translation_item', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'ollama_status_code' => $statusCode, + 'duration_ms' => elapsed_ms($requestStartedAt), + ]); translate_response(502, ['error' => 'Unexpected translation item']); } $originalPosition = $missPositions[$index]; @@ -289,11 +392,24 @@ foreach ($missPositions as $position) { translation_cache_write($cacheWrites); ksort($cleanTranslations); +translation_log([ + 'status' => 'success', + 'target_lang' => $targetLang, + 'model' => $ollamaModel, + 'text_count' => count($texts), + 'cache_hits' => $cacheHits, + 'cache_misses' => $cacheMisses, + 'ollama_latency_ms' => $ollamaLatencyMs, + 'failed_json_parse_count' => $failedJsonParseCount, + 'timeout_count' => $ollamaTimeoutCount, + 'ollama_status_code' => $statusCode, + 'duration_ms' => elapsed_ms($requestStartedAt), +]); echo json_encode([ 'translations' => array_values($cleanTranslations), 'model' => $ollamaModel, 'cache' => [ - 'hits' => count($texts) - count($missTexts), - 'misses' => count($missTexts), + 'hits' => $cacheHits, + 'misses' => $cacheMisses, ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);