Splunk Shadow AI Ingestion
Poll AI-usage / CASB-style activity events out of a Splunk index and forward them to Credo AI's Shadow AI Discovery surfaces.
This cookbook uses raw HTTP calls rather than an SDK method, since neither SDK (pycredoai, @credo-ai/sdk) has a shadow_ai method yet.
Shadow AI Discovery is a Research Preview feature and has no endpoint on
the public Integration Service yet. This cookbook gets its bearer token the
normal way — POST /auth/token on the Integration Service, same
X-API-Key/X-Tenant auth shape as every other cookbook on this site — but
sends the bulk event POST straight to the backend instead:
POST /api/v2/{tenant}/shadow_ai/ai_events/bulk.
That backend endpoint is not part of the stable public API contract — it can change shape or move without notice. Once Shadow AI ships on the public Integration Service, this cookbook should be migrated to use it instead.
How It Works
Your Splunk (index, sourcetype) ──▶ This Poller ──▶ Credo AI (Shadow AI events)
There's no webhook to register — Splunk doesn't push. This poller pulls on an interval using the same /services/search/jobs/export REST API any external tool would use to read Splunk data back out, transforms results into Credo AI's event schema, and posts batches to the bulk endpoint.
A few reliability details worth knowing:
- Checkpoint saved per batch, not per cycle. If the process is interrupted mid-cycle, at most the one batch in flight at that moment can be re-sent — everything already delivered earlier in the cycle isn't re-fetched.
- Permanently rejected batches don't stall the poller. If Credo AI rejects a batch outright (entitlement off, bad event data), that batch is logged and skipped, and the poller moves on to the next one instead of stalling the whole cycle on it. A transient failure (5xx, timeout), by contrast, holds the checkpoint back and retries the same batch next cycle.
- Client-side dedup. Since there's no backend idempotency key yet, the poller keeps an in-memory fingerprint of everything it has delivered in the current run, so a batch that's accidentally re-fetched (e.g. a checkpoint boundary tie) isn't sent twice in the same run.
The Poller
Runs as a long-lived process, not a request handler — there's nothing to expose or tunnel.
import json
import httpx
CREDO_BASE_URL = "https://your-tenant.credo.ai/api/v1/integration"
CREDO_API_KEY = "your-api-key"
CREDO_TENANT = "your-tenant-name"
# Backend host for the Shadow AI bulk endpoint — see the preview note above.
# Not the same host as CREDO_BASE_URL. Ask your Credo AI representative.
CREDO_BACKEND_BASE_URL = "https://your-backend-host"
SPLUNK_BASE_URL = "https://your-splunk-host:8089"
SPLUNK_INDEX = "your-index-name"
SPLUNK_SOURCETYPE = "your-sourcetype-name"
# Credo AI field -> Splunk field. Adjust the right side to match your data.
FIELD_MAP = {
"user_email": "user_email",
"app_name": "app_name",
"category": "category",
"action": "action",
"risk_score": "risk_score",
}
def fetch_events(earliest: str) -> list[dict]:
fields = ", ".join(FIELD_MAP.values())
spl = (
f"search index={SPLUNK_INDEX} sourcetype={SPLUNK_SOURCETYPE} "
f"earliest={earliest} | fields _time, {fields}"
)
resp = httpx.post(
f"{SPLUNK_BASE_URL}/services/search/jobs/export",
auth=("splunk-service-account", "splunk-password"),
data={"search": spl, "output_mode": "json"},
timeout=30,
)
resp.raise_for_status()
return [
json.loads(line)["result"]
for line in resp.text.splitlines()
if line.strip()
]
def send_batch(events: list[dict], token: str) -> str:
"""Returns "delivered", "dropped" (404/422 — permanent, safe to skip and
advance past), or "retry" (5xx — transient, caller should hold its
checkpoint and retry this batch next cycle).
Token comes from CREDO_BASE_URL's /auth/token (Integration Service), but
the bulk POST itself goes to the backend directly — see the preview note
above."""
resp = httpx.post(
f"{CREDO_BACKEND_BASE_URL}/api/v2/{CREDO_TENANT}/shadow_ai/ai_events/bulk",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"events": events},
timeout=60,
)
if resp.status_code in (404, 422):
return "dropped"
if resp.status_code >= 500:
return "retry"
resp.raise_for_status()
return "delivered"
The full, tested version of this poller — with checkpointing, retry/backoff, and graceful handling of a dropped batch — lives in credoai-recipes/integrations/splunk/shadow-ai-event-ingestion/server/python/main.py. The snippet above is illustrative; run the real file for anything beyond reading the shape of the integration.
Testing Locally
-
Install dependencies and run the poller:
cd server/pythonpip install -r requirements.txtpython main.py -
No ngrok needed — this isn't an inbound webhook receiver, it reaches out to both Splunk and Credo AI. You do need a Splunk instance with matching test data to poll against.
-
Confirm the Splunk side independently first:
curl -sk -u "$SPLUNK_USERNAME:$SPLUNK_PASSWORD" \"https://$SPLUNK_HOST:$SPLUNK_PORT/services/search/jobs/export" \-d "search=search index=$SPLUNK_INDEX sourcetype=$SPLUNK_SOURCETYPE earliest=-1h | head 5" \-d "output_mode=json"If this returns nothing, fix the index/sourcetype/credentials before going further.
See credoai-recipes/integrations/splunk/shadow-ai-event-ingestion/TESTING.md for the full test plan, including checkpoint-behavior and failure-mode checks.
Configure Splunk
- Create a read-only Splunk service account, scoped via Indexes searched by default to just the index carrying your AI-activity events — never an admin account.
- Point
.envat your Splunk instance's actualSPLUNK_HOST,SPLUNK_INDEX, andSPLUNK_SOURCETYPE. - Map your Splunk field names to Credo AI's schema in
FIELD_MAPif they differ from the defaults.
Production
Run the poller as a long-lived process — a systemd service, a container, or a scheduled task — wherever you run other internal tooling. There's no port to expose.