Twee meetinstrumenten keken naar dezelfde code en gaven tegengestelde uitkomsten.
Het 2026 State of AI Coding-rapport van New Relic, verschenen op 10 juni, meldt dat 94% van de technisch leidinggevenden AI-code hoger inschat dan code van mensen, gemeten op het moment dat die code gereviewd wordt. Van diezelfde 200 respondenten had 82% in het halfjaar ervoor minstens één productiestoring die aan AI-code toe te schrijven was.
De review keurde het goed. De productieomgeving dacht daar anders over.
Welk instrument zat ernaast
Geen van beide. Ze meten niet hetzelfde, en het gat ertussen is waar deze post over gaat.
Een review leest intentie. Iemand kijkt naar een diff en vraagt of dit een verstandige manier is om te doen wat er staat. Dat is een vraag over de code zoals die geschreven is.
Productie leest gedrag. Die vraagt wat er echt gebeurde toen dit draaide, met echte invoer, echte gelijktijdigheid en een dependency die net een slechte dag had. Dat is een vraag over het systeem.
Die twee vragen zijn altijd al verschillend geweest. Wat veranderd is, is de samenhang ertussen. Toen mensen de code schreven, zag je slordig denken meestal terug in slordige code, dus ving een review een flink deel van de gedragsproblemen indirect af. Bij AI-code werkt die omweg niet meer. In de code review-bottleneck staat het citaat van Faros AI dat uitlegt waarom: de code is "often superficially convincing: idiomatic, well-named, stylistically consistent with the surrounding codebase", met de structurele fouten eronder.
Idiomatisch en netjes benoemd is precies waar een review op afgestemd is. Het instrument dat het probleem vroeger indirect ving, geeft nu groen licht, en 94% van de leidinggevenden tekent ervoor.
De cijfers ertussenin passen daarbij. 78% ziet meetbaar meer productie-incidenten door AI-code, 86% ziet senior engineers vaker brandjes blussen, en 74% zegt dat minstens een kwart van hun AI-code het afgelopen jaar flink herschreven moest worden. Op 6 augustus legde Faros die uitkomsten naast hun eigen telemetrie: de verhouding incidenten per PR meer dan verdrievoudigd, bugs per developer 54% omhoog.
Wat je hiervoor inlevert
Reviewen blijft. Instrumenteren spant alleen een vangnet onder het stuk review dat je toch al niet meer deed, en dat vangnet is slechter dan zelf lezen, want het vangt dingen pas later op. Later betekent nadat een bezoeker het gevonden heeft.
Diezelfde enquête maakt de grens meteen zichtbaar. 96% van die leidinggevenden noemt observability zeer of uiterst belangrijk om AI-code beheersbaar te houden, en het rapport meldt erbij dat de meeste organisaties in de steekproef al een observability-stack draaien, een flink deel zelfs meer dan één tool. 82% had alsnog een productiestoring. Meten heeft geen van die storingen voorkomen. Wat het wel doet, is de afstand verkleinen tussen het moment dat er iets stuk is en het moment dat je weet wát er stuk is.
Even over de herkomst van die cijfers. New Relic verkoopt observability, en het rapport behandelt investeren als een gelopen race: de enige vraag die overblijft is op welk platform je standaardiseert. Dat is een verkoopconclusie op basis van echte enquêtedata, en juist die 82% is het interessantste deel, want dat cijfer ondergraaft het verhaal eromheen.
Faros komt vanuit dezelfde diagnose ergens anders uit: hun advies is om codekwaliteit aan de schrijfkant aan te pakken in plaats van de symptomen verderop te bestrijden. Doen. Dat is goedkoper en het is waar het probleem echt zit. Deze post gaat over het deel dat er tóch langsglipt, want 62% van de teams in die enquête stuurt AI-code nu al naar productie zonder regel voor regel te controleren, dat getal gaat niet dalen, en het alternatief voor een vangnet is geen vangnet.
Loggen is geen observability, en het verschil is een vraag
Het woord komt uit de regeltechniek, waar Rudolf Kálmán observability rond 1960 definieerde als een eigenschap van een systeem: hoe goed je de interne toestand kunt afleiden uit wat het naar buiten laat zien. Charity Majors trok die definitie de software in, en dit is het bruikbare deel ervan.
Loggen beantwoordt de vragen die je had toen je de logging schreef.
Observability is een vraag kunnen stellen die je toen nog niet had.
Dat onderscheid klinkt academisch tot je je eigen instrumentatie eens gaat lezen, en dat is wat ik gedaan heb.
Wat mijn eigen logging me niet kan vertellen
Deze site draait op een Express-server, op een machine die ik zelf beheer: statische releases, een atomaire publish en rollback, en een AI-chatendpoint dat op Gemini draait. Er zat al lang gestructureerde logging in voordat ik deze post schreef. server/utils/chatLogger.mjs schrijft per gebeurtenis één JSON-regel weg met tijdstempel, IP, taal, vraag, antwoord, duur, status en een detector voor prompt-injectie die abuse-termen en weigerpatronen markeert.
Dat is prima logging. Het is ook zo goed als nutteloos voor de vragen die ik uiteindelijk wilde stellen.
De echte log staat op de deployhost en niet in de repository. Wat hierna komt is dus een audit van de logging zoals die in de code staat. Vier vragen, en op geen ervan geeft de code antwoord.
Waar ging de tijd heen? In server/services/ai.mjs staat startTime op regel 9. De kennisbank wordt op regel 14 geladen. De duur die ik wegschrijf dekt dus het inlezen van alle contentbestanden, het doorzoeken van de blogindex, de aanroep naar Gemini zelf en eventuele wachttijd tussen pogingen, bij elkaar opgeteld tot één getal. Als de p95-latency morgen verdubbelt, weet ik dat die verdubbeld is. Ik weet niet welke van de vier omhoogging.
Zat er een retry in? De aanroep zit in een lus van maximaal drie pogingen, met een seconde en daarna twee seconden wachten na een 503 van Gemini. Een verzoek dat twee keer faalde, drie seconden wachtte en bij de derde poging slaagde, logt status: 200 en verder niets. Een leverancier die het even laat afweten en een bezoeker die een lange vraag typt, leveren dezelfde logregel op, alleen te onderscheiden aan een duur die volgens de vorige alinea toch al alles op één hoop gooit.
Wie was dit? Er zitten drie soorten gebeurtenissen in dat bestand: chat, security_block en rate_limit_block. Drie verschillende sets velden, en het enige dat alle drie delen is een IP-adres. Genoeg om naar een bezoeker te gissen, bij lange na niet genoeg om een verzoek te volgen. Als een rate-limit-blokkade en een chatregel een minuut na elkaar hetzelfde adres hebben, kan ik nergens aan zien dat het dezelfde persoon was, en al helemaal niet welk verzoek die blokkade uitlokte.
En dan de vraag die echt pijn doet. De kennisbank wordt op relevantie gefilterd: loadBlogIndex zoekt in de blogindex op de vraag van de bezoeker, en levert een lege string op als er niets matcht. De prompt wordt vervolgens zonder blogsectie samengesteld, geruisloos, en het model doet wat de systeemprompt voorschrijft als de kennisbank iets mist, namelijk zeggen dat het die informatie niet heeft. Nergens wordt vastgelegd dat het zoeken niets opleverde.
Daarna leest de abuse-detector dat antwoord. De weigeringstest is een lijst met substrings, waaronder sorry, helaas, kan niet, unable en cannot. Een antwoord waar één daarvan in voorkomt zet isRefusal: true, en die vlag alleen al zet isAbuse: true.
Neem nu een injectiepoging die de lijst met termen niet herkent, bijvoorbeeld de opdracht om vanaf nu alleen nog in het Frans te antwoorden. Die wordt netjes afgewezen, en levert precies die regel op: weigering gemarkeerd, geen abuse-term. Een zoekactie met nul resultaten levert dezelfde regel op.
In de tekstvelden is het verschil nog te zien, dus ik kan de twee uit elkaar houden door ze stuk voor stuk te lezen. Alle gestructureerde velden zijn identiek, dus ik kan er niet op filteren, niet op tellen en er geen melding op instellen. Het veld dat ze zou scheiden, of het zoeken überhaupt iets opleverde, is het veld waar niemand aan gedacht heeft.
Elke regel die eraan meedoet is correct, komt door de review en doet exact wat er staat.
Dit is de hele post in één bug: code waar je geen vragen over kunt stellen. Ik had het nooit gevonden door de diff te lezen, want ik heb die diff zelf geschreven en hij zag er prima uit.
Eén gebeurtenis per verzoek
De oplossing voor alle vier heeft dezelfde vorm, en die is ouder dan het huidige jargon. Stripe publiceerde in juli 2019 canonical log lines: naast de gewone logregels schrijft elk verzoek aan het eind één lange regel weg met de belangrijkste kenmerken. Jeremy Morrell verwoordt de moderne versie rechttoe rechtaan in zijn praktijkgids over wide events: schrijf per eenheid werk één gebeurtenis weg met alle informatie die je erover kunt verzamelen.
Eén rij per verzoek. Breed, plat, saai. Voor het endpoint hierboven komt dat ongeveer hierop neer:
{
request_id: 'req_8f2a...', // joins every row about this request
route: '/api/chat',
lang: 'nl',
status: 200,
duration_kb_ms: 84, // the four numbers that were one
duration_model_ms: 1310,
duration_retry_sleep_ms: 1000,
duration_total_ms: 2394,
model: 'gemini-3.1-flash-lite',
attempts: 2, // the retry, no longer invisible
upstream_status_first: 503,
kb_posts_matched: 0, // the retrieval miss, now a fact
kb_chars: 41208,
history_length: 3,
response_chars: 118,
flagged_refusal: true,
flagged_abuse_keyword: false,
}Er gebeurt hier niets slims. De vier onbeantwoordbare vragen werden vier velden, en met de request-id zijn de rijen over beveiliging en rate limiting eindelijk te koppelen aan de chatregel, in plaats van dat ze in hetzelfde bestand doen alsof ze iets met elkaar te maken hebben.
Twee regels maken het verschil. Alle velden moeten op dezelfde rij staan, want zodra een feit één rij verderop staat, ben je weer op tijdstempel aan het koppelen, in de hoop dat het klopt. En de rij moet velden met hoge cardinaliteit aankunnen: request-id's, gebruikers-id's, build-sha's en feature flags, want de vraag die je wilt stellen gaat vrijwel altijd over dat ene ding dat misging en niet over een gemiddelde.
Allebei sluiten ze tellers uit. Een metriek kan je vertellen dat het aantal weigeringen omhoogging. Alleen die brede rij kan je vertellen dat ze omhooggingen omdat kb_posts_matched bij allemaal nul was.
Wil je liever een standaard dan een vorm, dan kom je bij OpenTelemetry uit, waar de span-attributen precies dit idee zijn, met een transportformaat eromheen. Begin bij de vorm. Eén JSON-regel per verzoek in een bestand dat je kunt doorzoeken wint het van een half geconfigureerde collector, en het is ongeveer veertig regels werk.
Meet wat je niet kunt nalezen
In de keuze wat er op die rij komt zit het oordeel, en verifieerbaarheid is daarbij een bruikbaarder criterium dan hoe belangrijk iets voelt.
Alles wat je kunt bewijzen door de code te lezen, heeft geen veld nodig. Alles wat afhangt van de toestand van de wereld tijdens het draaien wel. Welke leverancier antwoordde, hoe lang dat duurde, of het antwoord uit de cache kwam, hoeveel resultaten het zoeken opleverde, welke release-sha het verzoek bediende, welke tak van de if er werkelijk liep.
Dat sluit bijna te mooi aan op AI-code, want de dingen waar een agent naast gokt zijn precies de dingen die de buitenwereld raken. De pure functie die hij schreef zit waarschijnlijk goed. Bij het retry-beleid, de fallback, het pad voor een leeg resultaat en de foutafhandeling gokte hij, en dat gokwerk kun je niet nalezen, want juist het nalezen heeft het goedgekeurd.
Meet dus dat gokwerk. Elke stille fallback krijgt een veld dat benoemt welk pad er gelopen is. Elk leeg resultaat krijgt een teller. Elke retry krijgt een pogingnummer. Dat zijn de saaie stukken code, en juist daarom heeft niemand ze zorgvuldig gereviewd en juist daarom halen ze je 's nachts uit bed.
Dit is de productiekant van de vraag die codekwaliteit meten bij AI-code aan de schrijfkant stelt. Complexiteit, duplicatie en fitness functions voor architectuur draaien allemaal zonder dat er iets live staat, en ze vertellen je wat de code ís. Niets op dat lijstje vertelt je wat die code deed.
De valkuil aan het eind
Nog één cijfer uit die enquête, en dat is het cijfer dat ik zou inlijsten. 78% van de teams laat de AI-tools nu vaak of altijd meteen logging-hooks, span-attributen en eigen metrieken meegenereren.
Zet dat naast de 62% die zonder regel voor regel te controleren naar productie stuurt, en het patroon ligt er dik bovenop. Het vangnet onder de code die niemand las, wordt geschreven door het ding dat niemand las.
Instrumentatie die een agent schrijft is meestal netjes, goed benoemd en stilistisch consistent, en dat is nou precies het probleem met al het andere in deze post. Een agent logt met alle plezier dat een functie werd aangeroepen, want dat valt af te leiden uit de code en is dus makkelijk te schrijven. Hij weet niet dat je zoekactie in de kennisbank geruisloos niets teruggeeft, want dat feit staat niet in de diff. Dat feit zit in wat er daarna gebeurt.
Telemetrie die alleen vastlegt wat de code zegt te doen, is een spiegel en geen meetinstrument. Hij geeft de review uitvoerig gelijk, in productie, tot in de eeuwigheid. Bepalen wat een systeem je moet kunnen vertellen vergt hetzelfde oordeel als bepalen of de logica klopt, en de pipeline was al de bottleneck voordat we dat oordeel aan de agent zelf gingen uitbesteden.
Ik ging kijken wat mijn eigen logging niet kon zien en vond een mislukte zoekactie die onder dezelfde abuse-vlag belandt als een injectiepoging die het model afwees, zonder één veld om die twee uit elkaar te houden. Die zat er al maanden in, dwars door elke review heen die ik gedaan heb, in code die ik zelf geschreven heb. Het instrument mankeerde niets. Ik had het alleen nooit een vraag gesteld waarvoor het niet gebouwd was.