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
TraceParentfra 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ærerSaksnummer,VirkemiddelIdogTraceParent), og tagger spanet medkai.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 | |
|---|---|
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 itraces; eventet sto for 79 % av alt loggvolum i test uten å svare på noe spørsmål. Slått av medbuilder.ConfigureTracing(t => t.EnableFirstResponseEvent(false))iNpgsqlPasswordless.BuildDataSource— selve kommando-spanene beholdes. Microsoft.IdentityModelhevet tilWarningi API-ensappsettings.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:
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 manglendecustomDimensions-verdi gir""i Kusto — en likhetssjekk mot en potensielt tom variabel uten enisnotempty-vakt matcher da all umerket telemetri.- Hvert LLM-kall gir to
dependencies-rader: semconv-spanet (type == "Other", navn erchatetterfulgt av modellen fragen_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.
- Verifiser at støyen er borte:
Forventet: FirstResponse og Idx begge 0, Total en brøkdel av før.
- Verifiser at Postgres-spanene overlevde:
| Bash | |
|---|---|
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.
-
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.
-
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. -
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 atseverityLevel >= 2faktisk fanger skip-raden («gull ikke friskt») — utfallskolonnen er verdiløs hvis begge utfall havner i samme bøtte. -
Bekreft at
cache_read/reasoning-kolonnene på LLM-dashbordet får tall. «Tokens per sak» lesergen_ai.usage.cache_read.input_tokensoggen_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. -
Slett det gamle, hånd-lagde
kai-platform-dashbordet irg-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. Seinfrastructure/modules/kai-platform/README.mdfor 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.