Add translation observability logs

This commit is contained in:
juvdiaz 2026-06-10 10:41:46 -06:00
parent 35c6c6f654
commit ab68569dbe
2 changed files with 139 additions and 4 deletions

View File

@ -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 client keepalive, and upstream keepalive to the Traefik backend. CSS and
JavaScript keep the edge's longer immutable asset policy. 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 ## Validation
Do not build images locally. The image is built and deployed by the Debian Do not build images locally. The image is built and deployed by the Debian

View File

@ -2,6 +2,8 @@
header('Content-Type: application/json'); header('Content-Type: application/json');
$requestStartedAt = microtime(true);
function translate_response(int $status, array $body): never { function translate_response(int $status, array $body): never {
http_response_code($status); http_response_code($status);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 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; 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 { function redis_encode_command(array $parts): string {
$command = '*' . count($parts) . "\r\n"; $command = '*' . count($parts) . "\r\n";
foreach ($parts as $part) { foreach ($parts as $part) {
@ -215,14 +233,32 @@ foreach ($texts as $index => $text) {
$missTexts[] = $text; $missTexts[] = $text;
} }
$cacheHits = count($texts) - count($missTexts);
$cacheMisses = count($missTexts);
$failedJsonParseCount = 0;
$ollamaTimeoutCount = 0;
$ollamaLatencyMs = null;
if (!$missTexts) { if (!$missTexts) {
ksort($cleanTranslations); 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([ echo json_encode([
'translations' => array_values($cleanTranslations), 'translations' => array_values($cleanTranslations),
'model' => $ollamaModel, 'model' => $ollamaModel,
'cache' => [ 'cache' => [
'hits' => count($texts), 'hits' => $cacheHits,
'misses' => 0, 'misses' => $cacheMisses,
], ],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit; exit;
@ -254,28 +290,95 @@ curl_setopt_array($curl, [
CURLOPT_TIMEOUT => 130, CURLOPT_TIMEOUT => 130,
]); ]);
$ollamaStartedAt = microtime(true);
$rawResponse = curl_exec($curl); $rawResponse = curl_exec($curl);
$curlError = curl_error($curl); $curlError = curl_error($curl);
$curlErrno = curl_errno($curl);
$statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$ollamaLatencyMs = elapsed_ms($ollamaStartedAt);
curl_close($curl); curl_close($curl);
if ($rawResponse === false || $statusCode < 200 || $statusCode >= 300) { 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]); translate_response(502, ['error' => 'Ollama request failed', 'status' => $statusCode, 'detail' => $curlError]);
} }
$ollamaResponse = json_decode($rawResponse, true); $ollamaResponse = json_decode($rawResponse, true);
if (!is_array($ollamaResponse) || !isset($ollamaResponse['response']) || !is_string($ollamaResponse['response'])) { 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']); translate_response(502, ['error' => 'Invalid Ollama response']);
} }
$translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response'])); $translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response']));
$translations = json_decode($translationJson, true); $translations = json_decode($translationJson, true);
if (!is_array($translations) || count($translations) !== count($missTexts)) { 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']); translate_response(502, ['error' => 'Unexpected translation response']);
} }
foreach ($translations as $index => $translation) { foreach ($translations as $index => $translation) {
if (!is_string($translation) && !is_numeric($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']); translate_response(502, ['error' => 'Unexpected translation item']);
} }
$originalPosition = $missPositions[$index]; $originalPosition = $missPositions[$index];
@ -289,11 +392,24 @@ foreach ($missPositions as $position) {
translation_cache_write($cacheWrites); translation_cache_write($cacheWrites);
ksort($cleanTranslations); 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([ echo json_encode([
'translations' => array_values($cleanTranslations), 'translations' => array_values($cleanTranslations),
'model' => $ollamaModel, 'model' => $ollamaModel,
'cache' => [ 'cache' => [
'hits' => count($texts) - count($missTexts), 'hits' => $cacheHits,
'misses' => count($missTexts), 'misses' => $cacheMisses,
], ],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);