Gå til innhold

Observabilitet⚓︎

Kai v2 sender traces, metrics og strukturerte logger til Application Insights via OpenTelemetry (Enova.Kai.ServiceDefaults). Uten en felles kontrakt for hvilke saksfelt som følger med ender enhver «vis meg sak X»-oppgave som et tekstsøk i loggmeldinger. Nøkkelkontrakten under løser det: faste, navngitte kai.*-nøkler som følger et span og en loggrad fra den blir laget til den lander i Application Insights.

Nøkkelkontrakten⚓︎

Nøklene er definert i KaiTelemetryKeys (Enova.Kai.Application.Common.Observability) — enhver ny nøkkel må inn der, ellers feiler TelemetryContractTests (den vokter mot "kai."-strengliteraler utenfor klassen).

Nøkkel Settes hvor
kai.saksnummer PipelineTelemetry.StartStep (pipeline-jobbene), logger.BeginScope per sak (batch-/polling-jobbene), API-middleware
kai.virkemiddel Samme steder som over, fra CaseWorkPayload.VirkemiddelId / DocumentWorkPayload.VirkemiddelId
kai.step StartStep — verdi er navnet på PipelineStep-en (FetchDocuments, ExtractDocument, Evaluation)
kai.job.function StartStep — FetchDocuments, ExtractDocument, RunCaseWorkflow
kai.filnavn, kai.opprinneligfilnavn ExtractDocumentJob
kai.regel.id, kai.regel.navn, kai.regel.utfall EvaluateRuleHandler
enduser.id, enduser.name API-middleware, kun autentiserte kall (OTel-semconv — ikke i KaiTelemetryKeys, se under)

kai.regel.id er en GUID (RegelId er en record struct rundt en Guid), og spannavnet er EvaluateRuleCommand for hver eneste regel. kai.regel.navn (Regel.Name, fra YAML-ens navn — f.eks. R008 Annen støtte) er derfor det eneste lesbare i telemetrien; uten den måtte et dashbord slå opp i regeltabellen for å si hvilken regel som kjørte.

enduser.id/enduser.name står i tabellen fordi de hører til det samme kontekstbildet, men de er ikke definert i KaiTelemetryKeys og ikke dekket av arkitekturvakten. De er OTel-semconv-nøkler, ikke kai.*-nøkler, og eies dermed av konvensjonen — ikke av klassen. Tabellen skal ikke leses som at klassen eier dem.

kai.job.id finnes ikke — ingen kode setter den, og en nøkkel uten avsender er en løgn i kontrakten.

Discovery setter aldri kai.step

PipelineStep-enumen har fire verdier (Discovery, FetchDocuments, ExtractDocument, Evaluation), men kai.step blir bare noen gang satt til de tre siste. Discovery-runden (SelectionJob, som velger hvilke saker som skal kjøres) er en polling-løkke, ikke et kø-konsumert steg — den kaller aldri PipelineTelemetry.StartStep og har derfor ingen kai.step-verdi, ingen span-tagg og ingen baggage for det steget. Se neste seksjon.

Hvordan sakskonteksten åpnes⚓︎

Det finnes tre inngangspunkt, og de gjør ulikt mye:

1. Pipeline-jobbene (FetchDocumentsJob, ExtractDocumentJob, RunCaseWorkflowJob) — én per kø-melding — åpner konteksten med PipelineTelemetry.StartStep(logger, step, traceParent, saksnummer, virkemiddelId). Den ene metoden gjør tre ting samtidig og returnerer en StepScope som lukker alt sammen på Dispose:

  • Starter et Consumer-span parentet til enqueue-tracen via TraceParent fra kø-payloaden (det er derfor et api-kall som trigger en jobb, og selve jobben, blir én sammenhengende transaksjon i App Insights — ingenting spesielt trengs på api→worker-hoppet utover at payloaden bærer Saksnummer, VirkemiddelId og TraceParent), og tagger spanet med kai.saksnummer, kai.virkemiddel, kai.step, kai.job.function.
  • Legger de samme tre sakfeltene (saksnummer, virkemiddel, step) i Activity.Baggage.
  • Åpner et ILogger-scope med de samme feltene.

ExtractDocumentJob setter i tillegg kai.filnavn/kai.opprinneligfilnavn direkte som span-tagger (ikke baggage — det er metadata om nettopp dette dokumentet, ikke noe et barnespan skal arve).

2. Batch-/polling-jobbene (SelectionJob = Discovery, PipelineReconciliationJob, BulkReevaluationJob) finner saker i en løkke; de er ikke selv et kø-konsumert steg. De åpner bare et ILogger-scope per sak via logger.BeginScope med kai.saksnummer (og kai.virkemiddel der det er kjent på det tidspunktet i løkken — PipelineReconciliationJob kjenner bare saksnummer før oppslaget). Ingen span, ingen baggage, ingen kai.step.

3. API-middlewaren i Program.cs tagger request-spanet med kai.saksnummer fra query-strengen (?saksnummer=) når den er gitt — aldri fra ruten, siden saksnummer inneholder en skråstrek og App Service 404-er på %2F i et path-segment — og kaller i tillegg AddBaggage med den samme nøkkelen. Det siste er det som gjør at også barnespanene på API-siden (Postgres-kall, utgående HTTP) arver saken, akkurat som i workeren; en tagg alene hadde stoppet på request-spanet. For autentiserte kall legger den også på enduser.id (Entra object id — samme identitet v1 har, så v1 og v2 kan unioneres på én nøkkel) og enduser.name (preferred_username/upn).

EvaluateRuleHandler er et eget tilfelle: den setter kai.regel.id/kai.regel.utfall direkte på det pågående spanet — informasjon om nettopp den regelkjøringen, ikke noe som skal arves videre.

Hvorfor baggage og ikke bare tagger⚓︎

En tagg gjelder bare spanet den ble satt på. Activity.Baggage er annerledes: den arves av alle barnespans i samme trace — også dem Npgsql, HttpClient, blob-klienten eller Content Understanding lager uten selv å vite om saken. BaggageEnrichingProcessor (Enova.Kai.ServiceDefaults) kopierer ethvert kai.*-baggage-element over på span-taggene ved OnEnd, med mindre spanet allerede har satt den taggen selv (et kallsted som eksplisitt har satt verdien er mer spesifikt enn arvet baggage). Prosessoren matcher på prefikset kai., ikke på navngitte nøkler — det er det som holder ServiceDefaults fri for prosjektreferanser til Enova.Kai.Application.

Uten dette er bibliotekenes spans anonyme, og et Postgres- eller LLM-kall dukker opp i telemetrien uten noen kobling til hvilken sak det gjaldt.

Baggage forlater prosessen — som en HTTP-header⚓︎

Activity.Baggage er ikke in-process-only, slik navnet lett kan leses. .NETs standard DistributedContextPropagator (W3CPropagator) injiserer baggage som en baggage-header på hvert utgående HttpClient-kall, ved siden av traceparent. Alt vi legger i baggage inne i et pipeline-steg går derfor ut på nettet.

Dette er målt, ikke antatt: en probe med et Consumer-span med kai.saksnummer/kai.virkemiddel i baggage, fulgt av et HttpClient-kall mot en lokal lytter, ga hos serveren

Text Only
1
2
3
propagator: W3CPropagator
baggage:    kai.virkemiddel = 6008106606, kai.saksnummer = 25/32511
traceparent: 00-03979c05…-00

Konsekvensen: saksnummer og virkemiddel når Websak (bak APIM), Mimir, Content Understanding og AI Foundry som en request-header.

Det er vurdert som akseptabelt: hvert av disse endepunktene kjenner allerede saksnummeret — det er nettopp saken vi spør dem om. Headeren avslører ingenting mottakeren ikke får i selve forespørselen.

Vil man likevel stoppe det, holder det ikke å slutte å bruke baggage — da mister BaggageEnrichingProcessor grunnlaget sitt, og bibliotekenes spans blir anonyme igjen. Det måtte gjøres ved å bytte ut propagatoren (DistributedContextPropagator.Current) med en som injiserer traceparent/tracestate, men ikke baggage. Det er en designbeslutning, ikke en opprydding, og den må gjøres varsomt: en propagator som droppes eller skrives feil tar traceparent med seg i fallet, og da ryker api→worker-sammenhengen som er hele grunnlaget for Sak-dashbordet.

Nivåpolicy⚓︎

Nivå Betyr
Information Milepæler i sakens livsløp — et steg startet/fullførte, en sak ble valgt, en beslutning ble tatt
Debug Detaljer inne i ett steg
Warning Noe feilet, men forsøkes på nytt
Error Permanent — saken går ikke videre uten manuell oppfølging

Støy fjernet ved kilden⚓︎

To støykilder ble fjernet der de oppstår, i stedet for filtrert bort i spørringen:

  • Npgsqls received-first-response-span-event — Azure Monitor-eksportøren flater span-events ut til egne rader i traces; eventet sto for 79 % av alt loggvolum i test uten å svare på noe spørsmål. Slått av med builder.ConfigureTracing(t => t.EnableFirstResponseEvent(false)) i NpgsqlPasswordless.BuildDataSource — selve kommando-spanene beholdes.
  • Microsoft.IdentityModel hevet til Warning i API-ens appsettings.json — nok et ~14 % av volumet.

Sampling ble bevisst ikke rørt — SamplingRatio = 1.0f, TracesPerSecond = null i Enova.Kai.ServiceDefaults/Extensions.cs. Distro-defaultene (rate-limited sampling) droppet tidligere pipeline-spans og alle ILogger-linjer inni dem under samtidighet, mens jobben likevel rapporterte suksess — full fidelity er poenget med telemetrien, nettopp når det er last.

Slik legger du til eller endrer et dashbord⚓︎

Fire dashbord (Microsoft.Dashboard/dashboards, Azure Monitor med Grafana — portalens Grafana-opplevelse, ingen egen Grafana-instans) er Terraform-styrt fra infrastructure/modules/kai-platform/resources.dashboards.tf: helse, sak, pipeline, llm. Hver av dem er en JSON-fil under infrastructure/modules/kai-platform/dashboards/*.json, registrert i local.dashboards-mappet i samme fil. Se infrastructure/modules/kai-platform/README.md for den fulle mekanismen (azapi-ressursformen, kravene til en miljø-uavhengig JSON-fil, og __APPINSIGHTS_ID__-plassholderen som Terraform bytter ut ved apply).

For å legge til et nytt dashbord: eksportér Grafana-JSON-en til en ny fil under dashboards/, og legg til en oppføring i local.dashboards:

Terraform
1
2
3
mittnye = {
  file = "${path.module}/dashboards/mittnye.json"
}

Mappet tar bare file — det finnes ikke noe display_name-felt; ressursnavnet Terraform gir dashbordet er alltid avledet av nøkkelen (kai-<key>-<resource_suffix>).

Sett enhet på varighetskolonner. duration i requests og dependencies er millisekunder, men et rått tall i en tabell sier ikke hvilken enhet det er. Sett unit til ms i panelets fieldConfig — som defaults.unit for graf-paneler, som en byName-override for tabellkolonner — så formaterer Grafana selv til 846 ms eller 3,2 min. Merk at OTel-metrikken gen_ai.client.operation.duration er sekunder, ikke millisekunder: enheten følger kilden, ikke kolonnenavnet.

Hvorfor spørringene ser slik ut⚓︎

Panelbeskrivelsene sier hva et panel viser. Her står hvorfor spørringene er skrevet som de er. Hver av dem er en feil vi allerede har hatt — les før du forenkler.

Tom variabel må gi tomt panel. tostring() av en dimensjon som ikke finnes gir "" i Kusto, og saksnummer-boksen er tom når dashbordet åpnes. Uten isnotempty(sak) blir sammenlikningen "" == "", som matcher all utagget telemetri i vinduet — ikke «ingen sak valgt», men en vegg av urelaterte data som ser ut som et svar.

Steg-spans ligger i requests, ikke i dependencies. StartStep lager dem med ActivityKind.Consumer, og Azure Monitor-eksportøren legger Consumer-spans i requests. Målt over 7 døgn i test: ExtractDocument 88, FetchDocuments 5, RunCaseWorkflow 4 — alle i requests, ingen i dependencies. Derfor union requests, dependencies.

Filtrer stegets eget span med kai.job.function, ikke kai.step. job.function settes kun som tagg, aldri som baggage. kai.step er baggage og arves av hvert eneste barnespan, så et kai.step-filter gir én rad per Npgsql-kommando og HTTP-kall i steget i stedet for én rad per steg.

Tell saker, ikke spans, i trakten. Av samme grunn: count() måler hvor mange spans et steg lager — Evaluation vifter ut i ett span per regel pluss LLM- og DB-barn — så stolpene ville målt span-prat, og trakten ville stige. dcount over kai.saksnummer gir den ekte trakten.

Logglinjer trenger to-leddet oppslag. Taggen sitter på spanene; traces-rader arver den bare hvis loggscopet var åpent. Første ledd finner transaksjonene saken berørte, andre ledd henter alt som skjedde i dem. Et enkelt where på traces dropper stille linjer uten scope.

Ett LLM-kall gir to rader. Det semantiske GenAI-spanet (type == "Other", med gen_ai.request.model) og det underliggende HTTP-spanet. Filtrer på modell-dimensjonen, aldri på spannavnet, ellers telles hvert kall dobbelt. Unntaket er 429: statuskoden finnes bare på HTTP- spanet, så det panelet filtrerer omvendt.

Metrikker har ingen sakskontekst. Baggage stempler spans, ikke metrics, så gen_ai.client.token.usage har ingen kai.saksnummer-dimensjon. Per sak må derfor leses fra dependencies, der token-verdiene er strenger og trenger toreal(). valueSum er dessuten summen av målingene i et eksportintervall, ikke varigheten til ett kall — den duger ikke som latens.

Tokens: bare input + output. Under OTel GenAI-semconv er cache_read en delmengde av input_tokens og reasoning en delmengde av output_tokens. Summerer du alle fire, dobbelttelles de to siste.

ReplikaSync har ingen span. SyncJob bruker ikke PipelineTelemetry, og TickerQ er ikke registrert som ActivitySource. Eneste spor er logglinjene, så panelet leser traces og projiserer de strukturerte feltene fra logg-malen i stedet for å parse meldingsteksten — da tømmer ikke en omformulering panelet. severityLevel skiller utfallene: Information = synket, Warning = hoppet over fordi gullet ikke var nyere, som er den interessante feilen.

For å endre et eksisterende dashbord: redigér JSON-filen direkte, aldri dashbordet i portalen. En portal-endring endrer bare det kjørende Grafana-dashbordet — den leses ikke tilbake til Terraform, og neste terraform apply overskriver den stille med det JSON-filen sier.

Å finne en sak⚓︎

Første forsøk: Sak-dashbordet. Skriv inn saksnummeret i dashbordvariabelen — tidslinjen viser fetch → extract → evaluate med regelutfall, uten en eneste håndskrevet KQL-spørring.

Andre forsøk: KQL, når dashbordet ikke dekker spørsmålet (f.eks. et felt utenfor det dashbordet viser). Filtrer på kontraktens nøkler, ikke tekstsøk — se .claude/skills/appinsights-kql/SKILL.md for oppskriftene. To fallgruver der er verdt å kjenne igjen på forhånd:

  • tostring() av en manglende customDimensions-verdi gir "" i Kusto — en likhetssjekk mot en potensielt tom variabel uten en isnotempty-vakt matcher da all umerket telemetri.
  • Hvert LLM-kall gir to dependencies-rader: semconv-spanet (type == "Other", navn er chat etterfulgt av modellen fra gen_ai.request.model — observert i test: chat gpt-4.1) og det rå HTTP-spanet. Filtrer på modell-dimensjonen, aldri på name has "chat".

Sjekkliste etter deploy til test⚓︎

Denne PR-en inneholder ingen kjørbar deploy-/verifikasjonssteg — det er bevisst. Sjekklisten under er det som gjenstår etter at branchen er pushet og pipelines/kai-api.yaml, kai-worker.yaml og kai-infra.yaml har rullet ut til test.

  1. Verifiser at støyen er borte:
Bash
az monitor app-insights query --app appi-kai-platform-test -g rg-kai-platform-test --subscription pv-kai-test --analytics-query 'traces | where timestamp > ago(2h) | summarize Total = count(), FirstResponse = countif(message == "received-first-response"), Idx = countif(message startswith "IDXN")' -o table

Forventet: FirstResponse og Idx begge 0, Total en brøkdel av før.

  1. Verifiser at Postgres-spanene overlevde:
Bash
az monitor app-insights query --app appi-kai-platform-test -g rg-kai-platform-test --subscription pv-kai-test --analytics-query 'dependencies | where timestamp > ago(2h) and type == "postgresql" | summarize Spans = count(), MedSak = countif(isnotempty(customDimensions["kai.saksnummer"]))' -o table

Forventet: Spans > 0 (eventet forsvant, ikke spanene), og MedSak > 0 så snart workeren har kjørt en sak — det er beviset på at BaggageEnrichingProcessor treffer bibliotekenes spans, som er hele poenget med kontrakten.

  1. Verifiser drilldown ende-til-ende: Åpne Sak-dashbordet i portalen, skriv inn et saksnummer som har gått gjennom pipelinen etter deploy, og bekreft at tidslinjen viser fetch → extract → evaluate med regelutfall — uten en eneste håndskrevet KQL-spørring.

  2. Eyeball to ting som ikke kunne verifiseres uten en faktisk deploy: Transaction Search-dyplenken på Sak-dashbordet, og at barchart-panelet på LLM-dashbordet faktisk rendrer.

  3. Bekreft at ReplikaSync-panelet på Pipeline-dashbordet faktisk fylles ut: at feltnavnene spørringen projiserer (Saker, Vm, Status, Vurd, Kobl, Dump, Siste) gir verdier og ikke tomme kolonner, og at severityLevel >= 2 faktisk fanger skip-raden («gull ikke friskt») — utfallskolonnen er verdiløs hvis begge utfall havner i samme bøtte.

  4. Bekreft at cache_read/reasoning-kolonnene på LLM-dashbordet får tall. «Tokens per sak» leser gen_ai.usage.cache_read.input_tokens og gen_ai.usage.reasoning.output_tokens, som dagens klientbibliotek kanskje aldri emitterer. Er de fortsatt tomme etter en ekte LLM-kjøring, slett de to kolonnene — en alltid tom kolonne er en påstand om at tallet er null.

  5. Slett det gamle, hånd-lagde kai-platform-dashbordet i rg-kai-platform-test (pv-kai-test). Terraform eier navnekonvensjonen, og et hånd-laget dashbord utenfor state ved siden av et Terraform-styrt er nøyaktig den doble sannheten dette arbeidet fjerner. Se infrastructure/modules/kai-platform/README.md for den eksakte kommandoen og begrunnelsen — ikke gjenta den her, den drifter da ut av synk med README-en.

Ikke rør kai-bruk — det er et annet hånd-laget dashbord, i rg-kai-platform-prod (pv-kai-prod, en annen subscription enn dashbordet over), som dekker v1-til-v2-bruk og er bevisst holdt utenfor Terraform. To forskjellige ressursgrupper i to forskjellige subscriptions — ikke forveksle dem, det er slik feil dashbord slettes.