From 5697b4e7fef4ca6189ec2eb933e54b951fb54b8b Mon Sep 17 00:00:00 2001 From: stefanwalcz Date: Thu, 2 Jul 2026 09:48:56 +0200 Subject: [PATCH] feat(pii): export PII/audit events as a Prometheus counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PII EventStore ring buffer is capacity-bound and meant for recent-audit browsing via /api/pii/events; operators also want a monotonic, scrape-friendly signal on /metrics — how many detections/masks/blocks per hour, per origin, and whether the filter stopped firing after a deploy (silent-failure class). EventStore.Record is the single choke point every producer already goes through (request middleware, response scrubbing, MITM proxy connects/intercepts), so one lazily-initialised counter there covers all paths without touching any producer: localai_pii_events_total{kind, origin, action, direction} Same lazy otel.Meter pattern as core/services/routing/billing, so the counter lands on the Prometheus-backed global MeterProvider installed by the monitoring service. No behaviour change; label cardinality is bounded (enum-like fields only, no pattern IDs or user IDs). Assisted-by: Claude Opus 4.8 (1M context) Signed-off-by: stefanwalcz --- core/services/routing/pii/metrics.go | 48 ++++++++++++++++++++++++++++ core/services/routing/pii/store.go | 1 + 2 files changed, 49 insertions(+) create mode 100644 core/services/routing/pii/metrics.go diff --git a/core/services/routing/pii/metrics.go b/core/services/routing/pii/metrics.go new file mode 100644 index 000000000000..5c62d24e9247 --- /dev/null +++ b/core/services/routing/pii/metrics.go @@ -0,0 +1,48 @@ +package pii + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// Prometheus counter for PII events. The EventStore ring buffer is +// capacity-bound and meant for recent-audit browsing; operators also want +// a monotonic, scrape-friendly signal ("how many detections/blocks per +// hour, did the filter stop firing after a deploy"). Record() is the +// single choke point every producer already goes through (request +// middleware, response scrubbing, MITM proxy connects/intercepts), so one +// counter here covers all paths without touching the producers. +// +// Initialised lazily on first Record so the package works no matter when +// (or whether) the Prometheus-backed global MeterProvider is installed — +// same pattern as core/services/routing/billing. +var ( + metricsOnce sync.Once + eventsCounter metric.Int64Counter +) + +func recordEventMetric(e PIIEvent) { + metricsOnce.Do(func() { + meter := otel.Meter("github.com/mudler/LocalAI") + c, err := meter.Int64Counter( + "localai_pii_events_total", + metric.WithDescription("PII/audit events recorded, labeled by kind, origin, action and direction"), + ) + if err == nil { + eventsCounter = c + } + }) + if eventsCounter == nil { + return + } + eventsCounter.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("kind", string(e.Kind)), + attribute.String("origin", string(e.Origin)), + attribute.String("action", string(e.Action)), + attribute.String("direction", string(e.Direction)), + )) +} diff --git a/core/services/routing/pii/store.go b/core/services/routing/pii/store.go index 9e7285384e1a..1e57c1c7d388 100644 --- a/core/services/routing/pii/store.go +++ b/core/services/routing/pii/store.go @@ -58,6 +58,7 @@ type memoryEventStore struct { } func (s *memoryEventStore) Record(_ context.Context, e PIIEvent) error { + recordEventMetric(e) s.mu.Lock() defer s.mu.Unlock() s.ring[s.cursor] = e