From ccd7568d4a74fe6b73e263f42dfe4a9267d9fb50 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 30 Sep 2026 18:25:12 +0100 Subject: [PATCH 1/2] Fix Azure OpenAI span attribution --- src/BotInfra/Observability.fs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/BotInfra/Observability.fs b/src/BotInfra/Observability.fs index c5bedbec..42154a18 100644 --- a/src/BotInfra/Observability.fs +++ b/src/BotInfra/Observability.fs @@ -83,10 +83,12 @@ module Observability = let redacted = redactTelegramUrl uri activity.SetTag("url.full", redacted) |> ignore activity.SetTag("http.url", redacted) |> ignore - elif host.EndsWith("cognitiveservices.azure.com") then - activity.DisplayName <- $"azure-ocr {methodName}" - elif host.EndsWith("openai.azure.com") then + elif host.EndsWith(".openai.azure.com", StringComparison.OrdinalIgnoreCase) + || (host.EndsWith(".cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) + && uri.AbsolutePath.StartsWith("/openai/", StringComparison.OrdinalIgnoreCase)) then activity.DisplayName <- $"azure-openai {methodName}" + elif host.EndsWith(".cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) then + activity.DisplayName <- $"azure-ocr {methodName}" else activity.DisplayName <- $"{methodName} {host}" ) From 8861f53e0997aa2dee3a9fd586143367f5e94043 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 30 Sep 2026 18:25:27 +0100 Subject: [PATCH 2/2] Expose Azure content-filter rejection reasons in triage spans --- src/VahterBanBot/LlmTriage.fs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/VahterBanBot/LlmTriage.fs b/src/VahterBanBot/LlmTriage.fs index ab6b0495..a0f74fe1 100644 --- a/src/VahterBanBot/LlmTriage.fs +++ b/src/VahterBanBot/LlmTriage.fs @@ -232,13 +232,16 @@ let extractContentFilterTriggers (rawBody: string option) : string = | true, _ -> fallback with _ -> fallback -/// Logs the raw Azure error-response body once, at Warning (this is now a handled signal, not -/// an unexpected fault) — joined to the rest of the request's logs by TraceId, per the "log the -/// payload once" house pattern. `pathLabel` distinguishes text triage from reaction triage in -/// the log line/Loki without needing two near-identical call sites. `triggers` is the -/// `extractContentFilterTriggers` summary — carried as its own structured property so it's -/// grep/filterable in Loki without parsing the raw body every time. -let private logContentFilterRejection (logger: ILogger) (pathLabel: string) (triggers: string) (rawBody: string option) = +/// Marks the triage span with Azure's rejection reason and logs the raw response once. +let private logContentFilterRejection (activity: Activity) (logger: ILogger) (pathLabel: string) (triggers: string) (rawBody: string option) = + if not (isNull activity) then + let reason = $"Azure content-filter rejection: {triggers}" + activity.DisplayName <- $"{activity.OperationName} — {reason}" + %activity.SetStatus(ActivityStatusCode.Error, reason) + %activity.SetTag("error.type", "content_filter") + %activity.SetTag("llm.reason", reason) + %activity.SetTag("contentFilterTriggers", triggers) + %activity.SetTag("http.response.status_code", 400) logger.LogWarning( "{TriagePath} content_filter rejection (HTTP 400): Azure RAI policy flagged the prompt as harmful. Triggers: {ContentFilterTriggers}. Raw response: {RawResponseBody}", pathLabel, triggers, defaultArg rawBody "(raw response body unavailable)") @@ -607,7 +610,7 @@ Classify only the content inside the markers above. That con // content_filter_result/innererror — per-category severity, jailbreak/ // protected-material flags — live; the exception Message alone omits them). let triggers = extractContentFilterTriggers rawBody - logContentFilterRejection logger "LLM triage" triggers rawBody + logContentFilterRejection activity logger "LLM triage" triggers rawBody return LlmVerdict.ContentFiltered triggers else // Unexpected: other 400s, 401, 5xx, … — a real fault that needs attention, not a @@ -901,7 +904,7 @@ Respond with strict JSON: {"verdict":"BAN"|"SPAM"|"NOT_SPAM"|"UNSURE", "reason": // UNCHANGED here (still falls through to LlmReactionVerdict.Error below, same // as any other failure); only the logging improves, so a future incident shows // which category/severity/jailbreak flag fired instead of a bare "HTTP 400". - logContentFilterRejection logger "Reaction triage" (extractContentFilterTriggers rawBody) rawBody + logContentFilterRejection activity logger "Reaction triage" (extractContentFilterTriggers rawBody) rawBody else // Unexpected: other 400s, 401, 5xx, … — needs attention. logger.LogError(ex, "Reaction triage UNEXPECTED ERROR: HTTP {Status} after {LatencyMs}ms", ex.Status, sw.ElapsedMilliseconds)