Dit is een Kotlin implementatie van de - in ontwikkeling zijnde - standaard Logboek Dataverwerkingen (LDV) van Logius. De library is bruikbaar vanuit Kotlin- en Java-projecten die op Quarkus draaien.
Vanuit het programma MijnOverheid Zakelijk sluiten we zoveel mogelijk aan op de standaarden uit het stelsel Generieke Digitale Infrastructuur: https://www.digitaleoverheid.nl/mido/generieke-digitale-infrastructuur-gdi/. Een van de onderdelen daarvan is de standaard Logboek Dataverwerkingen van Logius: Voor meer informatie over de LDV standaard, zie: https://github.com/Logius-standaarden/logboek-dataverwerkingen.
Dit Open Source project is opgezet om de LDV standaard eenvoudig aan nieuwe of bestaande Quarkus-applicaties (Java of Kotlin) toe te voegen.
- Quarkus - De library is gebouwd op CDI-interceptors (
@Logboek), JAX-RS (HttpHeaders, client filter) en MicroProfile Config. Quarkus levert deze API's; in de library zijn ze alsprovidedgedeclareerd. Andere runtimes, zoals Spring Boot, worden niet ondersteund. - Clickhouse of PostgreSQL database - Voor het opslaan van de logging wordt standaard ClickHouse gebruikt: https://clickhouse.com/. ClickHouse is geoptimaliseerd voor zeer grote volumes. Organisaties die liever PostgreSQL beheren, kunnen dat als backend kiezen via
logboekdataverwerking.dbms=postgresql(zie hieronder). - Verwerkingsactiviteiten register - Bij het loggen van de activiteit wordt verwezen naar een ID van een verwerkingsactiviteit in een activiteiten register. Meer informatie hierover is te vinden in de documentatie van de standaard. Hierbij wordt geen richtlijn opgegeven voor de technische implementatie en deze is daarom niet inbegrepen bij deze implementatie.
Om deze package te gebruiken moet je in je (maven) project de volgende variablen in je application.properties file toevoegen:
logboekdataverwerking.enabled=true
logboekdataverwerking.service-name=service-name
# Database backend: 'clickhouse' (standaard) of 'postgresql'.
logboekdataverwerking.dbms=clickhouse
# Optionele OpenTelemetry resource-attributen
# Worden alleen toegevoegd aan de standalone OpenTelemetry resource;
# in een Quarkus-container met quarkus-opentelemetry komen deze uit de Quarkus-config.
logboekdataverwerking.service-version=1.0.0
logboekdataverwerking.deployment-environment=production
# Span processor: 'simple' (standaard, aanbevolen) of 'batch' (afgeraden, niet conform de acknowledgement-MUST).
# Zie 'Span processor en acknowledgement' hieronder voor de trade-off.
logboekdataverwerking.span-processor=simple
# Wat te doen als het Logboek een schrijfactie weigert: 'fail-closed' (standaard) of 'fail-open'.
# Zie 'Span processor en acknowledgement' hieronder.
logboekdataverwerking.write-failure-policy=fail-closedConfigureer daarnaast alleen de backend die je bij dbms koos — niet beide. Bij dbms=clickhouse:
logboekdataverwerking.clickhouse.endpoint=http://localhost:8123
logboekdataverwerking.clickhouse.username=user
logboekdataverwerking.clickhouse.password=password
logboekdataverwerking.clickhouse.database=db_name
logboekdataverwerking.clickhouse.table=table_name
# Optioneel: time-out (seconden) voor ClickHouse-queries en -inserts. Standaard 30.
logboekdataverwerking.clickhouse.query-timeout-seconds=30Of, bij dbms=postgresql:
logboekdataverwerking.postgresql.url=jdbc:postgresql://localhost:5432/ldv_logging
logboekdataverwerking.postgresql.username=user
logboekdataverwerking.postgresql.password=password
logboekdataverwerking.postgresql.table=spans
# Optioneel: time-out (seconden) voor het controleren of de verbinding nog actief is. Standaard 5.
logboekdataverwerking.postgresql.connection-validation-timeout-seconds=5of application.yml (hier met dbms: clickhouse; vervang het clickhouse-blok door een postgresql-blok bij dbms: postgresql):
logboekdataverwerking:
enabled: true
service-name: service-name
service-version: 1.0.0
deployment-environment: production
span-processor: simple
write-failure-policy: fail-closed
dbms: clickhouse
clickhouse:
endpoint: http://localhost:8123
username: user
password: password
database: db_name
table: table_name
query-timeout-seconds: 30Als enabled=true is, valideert de library bij applicatiestart dat alle properties van de gekozen backend aanwezig en niet-leeg zijn (clickhouse.* bij dbms=clickhouse, postgresql.* bij dbms=postgresql). Ontbrekende of lege waarden geven een IllegalStateException met een lijst van de missende keys, in plaats van pas bij de eerste export te falen.
PostgreSQL is een alternatieve backend voor ClickHouse, bruikbaar waar PostgreSQL operationeel beter past. De attributes- en resource-velden worden opgeslagen als jsonb-kolommen.
De JDBC-drivers van beide backends zijn in deze library als optional gemarkeerd: ze komen niet transitief mee, zodat je applicatie alléén de driver van de gekozen backend hoeft te declareren (com.clickhouse:client-v2 óf org.postgresql:postgresql). Kies je een backend zonder de bijbehorende driver, dan faalt de applicatie luid bij start (zie de config-validatie hierboven).
De lokale databases draaien achter een Compose-profiel, zodat je alleen de gekozen backend start:
docker compose --profile clickhouse up -d # standaard backend
docker compose --profile postgresql up -d # alternatieve backendLet op (geldt voor beide backends): Bij een mislukte export worden de betreffende spans niet opnieuw aangeboden — geen enkele OpenTelemetry-spanprocessor (
batchofsimple) probeert een mislukte export opnieuw. De standaardcombinatiespan-processor=simple+write-failure-policy=fail-closedvoldoet aan de LDV-acknowledgement-eis: de applicatie ziet synchroon of de logregel is opgeslagen en laat de verwerking falen als dat niet zo is. Dat garandeert geen opslag bij een databasestoring, maar maakt een mislukking wél direct zichtbaar. Zet jespan-processor=batch, dan weet de applicatie niet óf de opslag is geslaagd en degradeertfail-closedtot log-only.
Hierna kun je endpoints voorzien van de @Logboek() annotatie:
@Logboek(name = "behandelen-aanvraag", processingActivityId = "1234")
Hierbij is name de beschrijving van je eigen trace log en processingActivityId is de verwijzing naar een Register met meer informatie over de Verwerkingsactiviteit.
Daarnaast kan er in de betreffende functie extra informatie aan de Span worden toegevoegd:
Kotlin:
@Inject
lateinit var handler: ProcessingHandler
@Inject
lateinit var logboekContext: LogboekContext
@GET
@Path("/{identificatieType}/{identificatieNummer}")
@Logboek(name = "test", processingActivityId = "1")
fun test(): Response {
val innerSpan = handler.startSpan("span-2", null)
val innerContext = LogboekContext().apply {
status = StatusCode.ERROR
dataSubjectId = "123"
dataSubjectType = "BSN"
processingActivityId = "4321"
}
handler.addLogboekContextToSpan(innerSpan, innerContext)
innerSpan.end()
logboekContext.dataSubjectId = "000000000"
logboekContext.dataSubjectType = "KVK"
logboekContext.status = StatusCode.OK
return Response.ok("Hello world").build()
}Java:
@Inject
ProcessingHandler handler;
@Inject
LogboekContext logboekContext;
@GET
@Path("/{identificatieType}/{identificatieNummer}")
@Logboek(name = "test", processingActivityId = "1")
public Response test() {
var innerSpan = handler.startSpan("span-2", null);
LogboekContext innerContext = new LogboekContext();
innerContext.setStatus(StatusCode.ERROR);
innerContext.setDataSubjectId("123");
innerContext.setDataSubjectType("BSN");
innerContext.setProcessingActivityId("4321");
handler.addLogboekContextToSpan(innerSpan, innerContext);
innerSpan.end();
logboekContext.setDataSubjectId("000000000");
logboekContext.setDataSubjectType("KVK");
logboekContext.setStatus(StatusCode.OK);
return Response.ok("Hello world").build();
}Om de database en OpenTelemetry functionaliteit uit te schakelen tijdens testen, stel je logboekdataverwerking.enabled=false in je test configuratie bestand:
test/resources/application.properties:
logboekdataverwerking.enabled=false
Wanneer uitgeschakeld, worden er geen verbindingen met de database gemaakt.
De LogboekInterceptor extraheert automatisch inkomende traceparent/tracestate headers, zodat een verwerking die door een andere organisatie is gestart in het eigen Logboek wordt voortgezet onder hetzelfde trace_id. Geneste @Logboek-acties krijgen de omsluitende actie als parent; alleen de buitenste actie neemt de inkomende traceparent over.
Voor de andere richting (uitgaande calls vanuit deze service naar een andere organisatie) registreer je LogboekClientRequestFilter op je JAX-RS / MicroProfile REST clients. De filter injecteert traceparent op elke uitgaande request op basis van de actieve OpenTelemetry-context:
Kotlin:
import nl.mijnoverheidzakelijk.ldv.client.LogboekClientRequestFilter
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@Path("/api")
@RegisterRestClient(configKey = "andere-organisatie")
@RegisterProvider(LogboekClientRequestFilter::class)
interface AndereOrganisatieClient { /* ... */ }Java:
@Path("/api")
@RegisterRestClient(configKey = "andere-organisatie")
@RegisterProvider(LogboekClientRequestFilter.class)
public interface AndereOrganisatieClient { /* ... */ }Of programmatisch:
val client = ClientBuilder.newClient().register(LogboekClientRequestFilter::class.java)Het LDV-attribuut dpl.core.foreign_operation.processor identificeert de andere partij in een cross-organisatie verwerking en hoort gezet te worden op de uitgaande kant: door applicatiecode op de actieve span, met de URL of identifier van de externe service. De interceptor zet dit attribuut niet automatisch; alleen de tracecontext wordt voor je gepropageerd.
De LDV-standaard stelt dat de applicatie moet kunnen weten dat een logregel daadwerkelijk is opgeslagen. Dit wordt bepaald door twee instellingen die samenwerken.
span-processor kiest de OpenTelemetry-processor:
simple(standaard, en de aanbevolen keuze):SimpleSpanProcessor. Elke span wordt synchroon geëxporteerd; de applicatie wacht op de bevestiging van de database voordat de request afrondt. Conform de acknowledgement-MUST, maar verhoogt p99-latency en koppelt request-doorlooptijd aan de beschikbaarheid van het Logboek.batch(afgeraden):BatchSpanProcessor. Spans worden asynchroon in batches geëxporteerd. De applicatie keert terug naar de aanroeper vóórdat de export bevestigd is, dusbatchvoldoet niet aan de acknowledgement-MUST: bij een JVM-crash tussen response en flush gaat de logregel stil verloren, enfail-closeddegradeert tot log-only.
Kies je tóch batch, doe dat dan als bewuste, gedocumenteerde afweging. De situaties die dat rechtvaardigen zijn een hoog verwerkingsvolume of acties met veel betrokkenen: de standaard vereist een aparte logregel per betrokkene, dus onder simple doet een verwerking met N betrokkenen N+1 synchrone inserts binnen de request. Bij ClickHouse telt daarbij dat de engine op grote, gebundelde inserts is ontworpen; veel kleine losse inserts leggen merge-druk op de MergeTree-tabel.
write-failure-policy bepaalt wat er gebeurt als de database een schrijfactie weigert:
fail-closed(standaard): bij een schrijffout gooit de interceptor eenLogboekWriteException, zodat een verwerking niet als afgerond-en-gelogd geldt terwijl de logregel niet is opgeslagen. Dit is de strikte lezing van de acknowledgement-MUST en koppelt het slagen van een verwerking aan de beschikbaarheid van het Logboek.fail-open: de schrijffout wordt gelogd (SEVERE) en de verwerking gaat door.
Afdwingen van fail-closed werkt alleen op de synchrone simple-processor: daar draait de export op dezelfde thread als de request, vlak voor het einde van de verwerking. Onder batch gebeurt de export op een achtergrond-thread en degradeert het beleid tot log-only; de wrapper logt daarover een waarschuwing bij het opstarten. Alleen de standaardcombinatie simple + fail-closed voldoet aan de acknowledgement-MUST.
fail-closed wordt éénmaal afgedwongen, door de buitenste @Logboek-actie. Een geneste actie gooit zelf niet: ze laat de schrijffout geregistreerd staan en rondt gewoon af, en pas nadat de buitenste actie klaar is gooit die de LogboekWriteException. Zo faalt de request als geheel zodra ergens in de keten een logregel niet is opgeslagen, terwijl businesscode tussen de acties de exceptie niet per ongeluk kan wegvangen (wat de garantie stilletjes zou uitschakelen) of kan aanzien voor een functionele fout van de geneste actie.
De afdwinging is thread-gebonden: een @Logboek-actie die op een andere thread draait dan haar aanroeper dwingt het beleid daar zelf af, ook wanneer de OpenTelemetry-context is gepropageerd (die propagatie bepaalt alleen de parent-relatie van de logregel). De registratie van schrijffouten is namelijk per thread; uitstellen tot de buitenste actie zou de fout op een andere thread onzichtbaar maken. De keten-brede afdwinging op de buitenste actie geldt dus binnen één thread.
Gaat een export mis, dan wordt zo veel mogelijk gered: het mappen van een span naar een databaserij gebeurt per span, dus één onverwerkbare span laat de rest van de batch niet sneuvelen. De insert zelf is wél alles-of-niets — een half weggeschreven batch is een niet te interpreteren logregel. In beide gevallen levert verlies een mislukte export op (dus fail-closed slaat aan) en worden de trace_id:span_id van de verloren logregels op SEVERE gelogd.
Wie de acknowledgement-eis strikt neemt, schrijft de logregel vóór de verwerking: simple + fail-closed, en pas na enforceWriteAcknowledgement de opslag of levering. Kan de logregel niet worden geschreven, dan vindt de verwerking niet plaats. Op dat moment is de uitkomst nog onbekend. Faalt de verwerking daarna, dan blijft de logregel met status UNSET staan, wat de standaard leest als "afgerond zonder systeemfout". Een geëxporteerde logregel is definitief en de tabel is insert-only.
De mislukte uitkomst leg je daarom vast als eigen logregel. recordFailedOutcome schrijft per bevestigde logregel één ERROR-regel met de oorspronkelijke regel als parent_span_id, dezelfde naam, dezelfde verwerkingsactiviteit, dezelfde betrokkene en de exception.*-attributen (stacktrace alleen bij opt-in). Het attribuut moza.ldv.uitkomst=mislukt onderscheidt een uitkomst-regel van de per-betrokkene regels, die dezelfde parent dragen. Het succespad schrijft niets extra.
addLogboekContextToSpan geeft alle geëxporteerde regels terug: de actie-regel, en bij meerdere betrokkenen ook de regel per betrokkene. Iedere regel krijgt zijn eigen uitkomst-regel, zodat de uitkomst vindbaar is vanaf de actie-regel én vanaf de regel die een betrokkene bij een inzage ziet.
Leesregel: een logregel zonder ERROR-child is geslaagd. Dit is een MOZa-afspraak; de standaard kent deze leesregel nog niet (Logius-standaarden/logboek-dataverwerkingen#314). Een logregel voor een verwerking die uiteindelijk niet doorging is binnen de standaard acceptabel: over-rapporteren is de veilige kant.
// Buiten een @Logboek-actie: een pooled thread kan nog een schrijffout van een eerdere request dragen.
LogboekWriteFailureRecorder.clear()
val span = handler.startSpan("aanleveren", null)
val context = LogboekContext().apply {
processingActivityId = "https://register.example.org/activiteiten/aanleveren"
dataSubjectId = "000000000"
dataSubjectType = "KVK"
}
val logregels = handler.addLogboekContextToSpan(span, context)
span.end()
handler.enforceWriteAcknowledgement()
try {
lever(bericht)
} catch (e: Exception) {
val zonderUitkomst = handler.recordFailedOutcome(logregels, e)
if (zonderUitkomst.isNotEmpty()) {
alarmeer(zonderUitkomst)
}
throw e
}recordFailedOutcome heeft geen @Logboek-actie en geen actieve request nodig: de parent komt uit de Logregel, niet uit de huidige context. Wie zijn spans zelf beheert en alleen een SpanContext bewaart, bouwt de Logregel zelf. De methode vangt alles af, ook een JVM Error: een schrijffout van de uitkomst-regel mag de oorspronkelijke fout niet maskeren. De logregels waarvan de uitkomst-regel niet is opgeslagen komen terug als resultaat, en staan met hun trace_id:span_id in één SEVERE-regel. Een lege lijst betekent dat iedere uitkomst-regel bevestigd is; een niet-lege lijst betekent onder-rapportage, want zonder ERROR-child leest die logregel als geslaagd. Wat daarop volgt is aan de afnemer: opnieuw proberen of alarmeren. Dit verlies is alleen zichtbaar onder simple, waar de export op de eigen thread loopt; onder batch meldt alleen de exporter zelf het verlies en is de lijst dus altijd leeg. Een schrijffout die een omliggende actie nog had openstaan blijft staan voor de fail-closed-controle van die actie.
Error-logregels krijgen altijd exception.type en exception.message; bij meerdere betrokkenen draagt iedere betrokkene-logregel dezelfde foutdata (conform de foutdata-velden uit de standaard). De volledige exception.stacktrace wordt alleen opgeslagen als logboekdataverwerking.log-exception-stacktrace=true; standaard staat dit uit, omdat stacktraces groot zijn en persoonsgegevens kunnen bevatten (dataminimalisatie, AVG art. 5(1)(c)). Houd om dezelfde reden persoonsgegevens buiten exception-messages: het bericht wordt ongefilterd in het Logboek opgeslagen, gekoppeld aan de betrokkene.
LDV-spans gebruiken altijd een eigen, toegewijde OpenTelemetry-SDK met een AlwaysOn-sampler, ook wanneer de host-applicatie zelf een OpenTelemetry-SDK levert (bijv. quarkus-opentelemetry). Dit voorkomt dat logregels worden weggesampled door de sampler van de host of door een inkomende traceparent met sampled-flag 0, wat in strijd zou zijn met de LDV-eis dat Log Sampling niet is toegestaan. De toegewijde SDK wordt niet globaal geregistreerd en bestaat naast een eventuele host-SDK; tracecontext blijft propageren omdat de W3C-propagator en OTel-Context SDK-onafhankelijk zijn.
De standaard vereist een aparte logregel per betrokkene. Voor het enkelvoudige geval zet je dataSubjectId/dataSubjectType op de LogboekContext. Verwerk je meerdere betrokkenen in één actie (bijv. een batch), gebruik dan logboekContext.addSubject(id, type) per betrokkene: de interceptor maakt dan één child-logregel per betrokkene onder de actie-span, met hetzelfde trace_id.
Validatie breekt de verwerking nooit (LDV-standaard, foutafhandeling): ontbreekt processing_activity_id, ontbreekt een betrokkene of is de activity-id geen absolute URI, dan wordt dit als WARNING gelogd (met trace_id:span_id) en wordt de logregel geëxporteerd met de attributen die wél aanwezig zijn. Een lege @Logboek-naam valt terug op de methodenaam. Een logregel zonder betrokkene is toegestaan (de standaard staat 0 of 1 betrokkenen per logregel toe, bijvoorbeeld bij verwerkingen zonder persoonsgegevens); de warning helpt om vergeten context snel op te sporen.
Gooit de geïntercepteerde methode zelf een exceptie, dan wordt de logregel geëxporteerd met status ERROR en de exception.*-attributen; bij meerdere betrokkenen krijgen ook de child-logregels status ERROR. De oorspronkelijke exceptie wordt nooit gemaskeerd door een fout uit de logging zelf.
Niet elke exceptie is een fout van de verwerking. De standaard reserveert Error voor systeemfouten (database-timeout, API onbereikbaar) en schrijft Unset voor wanneer een verwerking zonder systeemfout afrondt maar geen resultaat oplevert. Kondig zo'n uitkomst aan met logboekContext.expectException(e) vlak vóór de throw: de logregel krijgt Unset in plaats van Error en geen exception.*-attributen, ook niet op de child-logregels. De exceptie zelf gaat ongewijzigd naar de aanroeper.
De aankondiging is gebonden aan de exceptie zelf, op identiteit. Gooit de actie daarna iets anders, dan is dat gewoon een fout. Dezelfde exceptie die door meerdere geneste @Logboek-acties omhoog komt, blijft op elk niveau aangekondigd; na afloop van de buitenste actie is de aankondiging verbruikt, zodat een hergebruikte exceptie-instantie niet aangekondigd blijft voor latere acties in dezelfde request. Een tweede aankondiging vervangt de eerste. Vang je de aangekondigde exceptie zelf af en loopt de actie door, trek de aankondiging dan in met clearExpectedException() en zet daarna zelf de status. Een expliciete status kan mee: expectException(e, StatusCode.OK).
Fail-closed geldt ook voor een aangekondigde uitkomst: kan de logregel niet worden opgeslagen, dan gooit de buitenste actie een LogboekWriteException, met de aangekondigde exceptie als suppressed. Alleen een niet-aangekondigde fout die al propageert wordt nooit door een schrijffout gemaskeerd.
NotFoundException nietGevonden = new NotFoundException();
logboekContext.expectException(nietGevonden);
throw nietGevonden;Zie RELEASING.md voor het publiceren van nieuwe versies naar Maven Central.