Copy-pasteable SQL for querying your PromptGuard telemetry — p95 latency, cost by model, high-risk users, blocked-over-time, threats by detector, and daily roll-ups.
Every guarded request writes one row to the security_events table (input and output are separate rows). Self-hosted deployments own that Postgres database directly, so you can point psql, a BI tool, or your warehouse’s foreign-data wrapper at it and run these recipes as-is. On PromptGuard Cloud, pull the same data through the Interactions API and load it into your own store.
There is no pre-aggregated roll-up table today — these recipes run directly against the raw security_events (and, for configuration/auth activity, audit_events) event tables. The Daily and hourly roll-ups section shows how to build your own materialized view if you want cheaper dashboards.
One row per policy evaluation. The columns that matter for analytics:
Column
Type
Notes
created_at
timestamptz
Event time. Index exists on (user_id, created_at).
decision
varchar(20)
allow | block | redact
threat_type
varchar(50)
injection, pii, toxicity, data_exfiltration, … (NULL when allowed)
confidence
float
0.0–1.0
processing_time_ms
float
Engine-only latency. Excludes the downstream LLM call on proxied requests.
model
varchar(100)
Upstream model, e.g. gpt-4o-mini (NULL on pure /guard scans)
provider
varchar(50)
openai, anthropic, …
surface
varchar(20)
proxy | sdk | guard | browser | desktop. Separates Shadow AI traffic from the developer path. NULL on historical rows.
end_user_id
varchar(255)
Per-end-user attribution. Populated when the caller sends the X-End-User header.
country_code / region
char(2) / varchar(100)
GeoIP enrichment, filled lazily.
tokens_input / tokens_output
integer
From the upstream response. NULL on /guard scans that never proxy.
cost_usd_estimate
numeric(12,6)
USD cost = model pricing × tokens. NULL on scans. Sub-cent precision preserved.
direction
varchar(10)
input | output
event_metadata
json
Free-form. Holds detector (regex | ml | agentic | a detector name), route_preset, content_preview, and per-detector latency_ms.
triage_status
varchar(20)
open | in_review | resolved
security_events is retention-bounded. Rows are purged on a per-plan schedule (Free 24h, Pro 7d, Scale 30d, Enterprise 90d / custom). A query over a 90-day window on a Pro plan returns at most 7 days of rows. For durable analytics, raise your retention (Enterprise custom_retention_days) or stream events into your own warehouse. audit_events is never purged.
Engine processing time by percentile over the last 24 hours. Use percentile_cont for interpolated percentiles.
SELECT percentile_cont(0.50) WITHIN GROUP (ORDER BY processing_time_ms) AS p50_ms, percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms) AS p95_ms, percentile_cont(0.99) WITHIN GROUP (ORDER BY processing_time_ms) AS p99_ms, count(*) AS eventsFROM security_eventsWHERE created_at > now() - interval '24 hours';
Break the same percentiles down by surface to compare the developer proxy against Shadow AI scans:
SELECT surface, percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms) AS p95_ms, percentile_cont(0.99) WITHIN GROUP (ORDER BY processing_time_ms) AS p99_ms, count(*) AS eventsFROM security_eventsWHERE created_at > now() - interval '24 hours'GROUP BY surfaceORDER BY p99_ms DESC NULLS LAST;
processing_time_ms is the time PromptGuard’s engine spent, not end-to-end request time. On the proxy path it excludes the upstream provider call. See Latency Budgets for what each detector contributes.
Spend and token volume grouped by upstream model. cost_usd_estimate is NULL on /guard-only scans (they never call a provider), so filter them out.
SELECT provider, model, count(*) AS requests, sum(tokens_input) AS tokens_in, sum(tokens_output) AS tokens_out, round(sum(cost_usd_estimate), 4) AS cost_usdFROM security_eventsWHERE created_at > now() - interval '30 days' AND cost_usd_estimate IS NOT NULLGROUP BY provider, modelORDER BY cost_usd DESC;
Cost attributed to blocked requests (spend you avoided by blocking, or spend on requests that were redacted then still forwarded):
SELECT decision, count(*) AS requests, round(sum(cost_usd_estimate), 4) AS cost_usdFROM security_eventsWHERE created_at > now() - interval '30 days' AND cost_usd_estimate IS NOT NULLGROUP BY decisionORDER BY cost_usd DESC;
Ranks callers by blocked-request volume. Uses end_user_id (populated when you send the X-End-User header); fall back to user_id for account-level attribution.
SELECT coalesce(end_user_id, user_id::text) AS actor, count(*) FILTER (WHERE decision = 'block') AS blocks, count(*) FILTER (WHERE decision = 'redact') AS redactions, count(*) AS total_events, round( 100.0 * count(*) FILTER (WHERE decision = 'block') / nullif(count(*), 0), 1 ) AS block_pctFROM security_eventsWHERE created_at > now() - interval '7 days'GROUP BY actorHAVING count(*) FILTER (WHERE decision = 'block') > 0ORDER BY blocks DESCLIMIT 50;
Drill into a single high-risk user’s most recent blocks, including the threat type and detector that fired:
SELECT created_at, threat_type, event_metadata->>'detector' AS detector, confidence, event_metadata->>'content_preview' AS previewFROM security_eventsWHERE end_user_id = '<end-user-id>' AND decision = 'block'ORDER BY created_at DESCLIMIT 100;
decision/threat_type tell you what was caught; the detector that caught it lives in event_metadata->>'detector' (values include regex, ml, agentic, and specific detector names). This recipe attributes blocks to the detection layer that fired — useful for tuning which layers earn their latency.
SELECT threat_type, coalesce(event_metadata->>'detector', 'unknown') AS detector, count(*) AS hits, round(avg(confidence), 3) AS avg_confidence, percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms) AS p95_msFROM security_eventsWHERE created_at > now() - interval '7 days' AND decision IN ('block', 'redact')GROUP BY threat_type, detectorORDER BY hits DESC;
Threat mix by surface (are Shadow AI pastes producing different threats than the developer proxy?):
SELECT surface, threat_type, count(*) AS hitsFROM security_eventsWHERE created_at > now() - interval '7 days' AND threat_type IS NOT NULLGROUP BY surface, threat_typeORDER BY surface, hits DESC;
There is no roll-up table shipped, so long-window dashboards scan raw events every load. If that gets expensive, materialize a daily summary yourself. This view is safe to REFRESH on a schedule (nightly, or hourly with CONCURRENTLY):
CREATE MATERIALIZED VIEW IF NOT EXISTS security_events_daily ASSELECT date_trunc('day', created_at) AS day, surface, provider, model, count(*) AS events, count(*) FILTER (WHERE decision = 'block') AS blocked, count(*) FILTER (WHERE decision = 'redact') AS redacted, sum(coalesce(cost_usd_estimate, 0)) AS cost_usd, sum(coalesce(tokens_input, 0)) AS tokens_in, sum(coalesce(tokens_output, 0)) AS tokens_out, percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms) AS p95_msFROM security_eventsGROUP BY 1, 2, 3, 4;-- A unique index lets you REFRESH ... CONCURRENTLY without locking readers.CREATE UNIQUE INDEX IF NOT EXISTS idx_security_events_daily_key ON security_events_daily (day, surface, provider, model);
Because security_events is retention-bounded, a materialized view built from it inherits the same horizon — refreshing after a purge drops the aged-out days. To keep history beyond your plan’s retention, INSERT each day’s roll-up into a plain table you own (self-host) or into your warehouse (Cloud) before the source rows are purged, rather than relying on the view alone.
An INSERT-based accumulator that survives purges:
CREATE TABLE IF NOT EXISTS security_events_daily_history ( day date PRIMARY KEY, events bigint, blocked bigint, redacted bigint, cost_usd numeric(14,6), p95_ms double precision);INSERT INTO security_events_daily_historySELECT date_trunc('day', created_at)::date, count(*), count(*) FILTER (WHERE decision = 'block'), count(*) FILTER (WHERE decision = 'redact'), sum(coalesce(cost_usd_estimate, 0)), percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms)FROM security_eventsWHERE created_at >= current_date - interval '1 day' AND created_at < current_dateGROUP BY 1ON CONFLICT (day) DO UPDATE SET events = excluded.events, blocked = excluded.blocked, redacted = excluded.redacted, cost_usd = excluded.cost_usd, p95_ms = excluded.p95_ms;
Schedule that with pg_cron (self-host / Supabase) or any external scheduler:
SELECT cron.schedule( 'rollup-security-events-daily', '10 0 * * *', -- 00:10 UTC daily $$INSERT INTO security_events_daily_history SELECT date_trunc('day', created_at)::date, count(*), count(*) FILTER (WHERE decision = 'block'), count(*) FILTER (WHERE decision = 'redact'), sum(coalesce(cost_usd_estimate, 0)), percentile_cont(0.95) WITHIN GROUP (ORDER BY processing_time_ms) FROM security_events WHERE created_at >= current_date - interval '1 day' AND created_at < current_date GROUP BY 1 ON CONFLICT (day) DO UPDATE SET events = excluded.events, blocked = excluded.blocked, redacted = excluded.redacted, cost_usd = excluded.cost_usd, p95_ms = excluded.p95_ms;$$);