Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Inline Python

Everything the sandbox gives your snippet, and everything it does not.

The Execution Model

Events arrive in micro-batches, 32 at a time by default. For each batch your snippet runs once, with events bound to that batch. The snippet is compiled once per search, not once per batch, so a ten-million-event search parses your source exactly once.

begin-run    compile the snippet, build the namespace     once per search
execute-batch  run the compiled code over one batch       once per 32 events
end-run      clear the namespace                          once per search

The namespace persists across batches within a search. That is deliberate and useful:

index=web
| exec inline="
try:
    seen
except NameError:
    seen = set()

for e in events:
    e['first_time_seen'] = e['dest_host'] not in seen
    seen.add(e['dest_host'])"

seen survives from batch to batch, so first-seen detection works across the whole search rather than resetting every 32 events.

The namespace is cleared between searches. A pooled interpreter reused by somebody else's search never sees your variables, and never sees your credentials.

A Snippet Sees One Batch at a Time

This shapes more code than anything else on this page.

events holds 32 events, not the whole result set. Anything that needs a global view has to happen either in SPL or across batches using the persistent namespace.

Sorting. Sorting inside a snippet orders 32 events. Sort in SPL, after the command:

| exec inline="for e in events: e['score'] = compute(e)"
| sort - score

Totals and rankings. len(events) is the batch size, not the result count. Aggregate with stats before the command, or accumulate across batches and emit at the end.

Accumulating. When you build up findings across batches, emit only what each batch discovered, or every later batch re-emits everything found so far:

try:
    findings, emitted = [], 0
except NameError:
    pass

# ... append to findings ...

events = findings[emitted:]
emitted = len(findings)

Deduplicating. A set in the namespace works across the whole search, because the namespace persists between batches within one search.

Raising batch_size makes batches larger but never makes them the whole result set. If you need everything at once, reduce with stats first.

Names in Scope

NameWhat it is
eventsThe current micro-batch: a list of dictionaries.
http_request(method, url, headers=None, body=None, timeout=30)Outbound HTTP. Returns {'status', 'headers', 'body'}, where body is bytes.
splunk_request(method, path, body=None, headers=None)Splunk REST call using this search's credentials and base URL.
serviceAn authenticated Splunk SDK Service, if the SDK is installed. See The Splunk SDK.
search(spl, earliest='-24h', latest='now', mode='oneshot', cap=10000)Run a search, get a list of dictionaries. Needs the SDK.
kv(collection)A KV store collection: get, find, insert, update, delete, batch_save. Needs the SDK.
datamodel(name)A Splunk data model: inspect its objects and fields, or conform() a batch to one.
paramskey=value arguments from the search, as a dictionary of strings.
lookups_dirs()List of guest paths where Splunk lookup files are mounted.
auth_headersThis search's Splunk credentials, as a list of pairs. Deprecated: splunk_request no longer needs them.
base_urlThis Splunk instance's management URI.
contextRun metadata: app, sid, owner, platform, generating.
BabysoarusErrorRaise it to fail the batch with a specific message.
json, re, datetime, math, os, timePre-imported for convenience.

Everything Is a String

Splunk's data model is text. A field that looks like a number arrives as '42', and must be converted before arithmetic:

# Wrong: string concatenation, silently
e['total'] = e['bytes_in'] + e['bytes_out']

# Right
e['total'] = int(e['bytes_in']) + int(e['bytes_out'])

Multivalue fields arrive as a Python list of strings, and a list you assign becomes a multivalue field on the way back:

| makeresults
| eval tags=split("alpha,beta,gamma", ",")
| exec inline="for e in events: e['tag_count'] = len(e['tags'])"

Missing fields are simply absent from the dictionary, so use .get() when a field is optional:

user = e.get('user', 'unknown')

Any key=value on the command that is not inline, function, batch_size or autocast is passed through to your code as params:

index=auth | exec function=detections/password_spray min_users=8 window=5m
threshold = int(params['min_users'])
window = params.get('window', '10m')

Values are strings, because that is what Splunk gives the command. Convert what you need, and use .get() with a default for anything optional.

params is always defined, and empty when nothing was passed, so a snippet does not have to guard for its absence.

This is what lets one function be tuned rather than copied. A detection whose threshold is a parameter is one function used at several sensitivities; a detection whose threshold is hard-coded becomes a new copy each time.

Typing Fields Automatically

autocast=true infers a type for each value instead, so arithmetic works without converting first:

index=web | exec autocast=true inline="for e in events: e['total'] = e['bytes_in'] + e['bytes_out']"

It is off by default, and the rest of this page assumes it is off.

Values are inferred in this order: integer, float, boolean (true, false, yes, no), ISO-8601 timestamp, then JSON object or array. Anything else stays text. Multivalue fields are typed element by element.

Values that only look like numbers are deliberately left alone, because turning them into numbers would destroy them:

ValueResultWhy
007textLeading zeros mean it is an identifier, not a quantity
1_0textPython would read this as 10
123456789012345678textToo long to survive as a JSON number exactly
1e400textLarger than a float can hold
1integer, not TrueA bare 1 in Splunk data is nearly always a count
1755300000integer, not a dateA bare epoch is nearly always an identifier

_raw, _time and every other field beginning with an underscore are never touched, because they belong to Splunk rather than to your data.

Two things worth knowing before you turn it on. A timestamp comes back out in full ISO-8601 form, so 2026-08-16 leaves as 2026-08-16T00:00:00. And a field is typed per value, so a field holding 7 in one row and n/a in the next gives you an integer and a string. That is deliberate: Splunk has no per-field schema, and a micro-batch is too small a sample to decide one from.

autocast=strict fixes each field's type from the first batch and keeps it, rather than deciding per value:

index=web | exec autocast=strict inline="for e in events: e['ratio'] = e['bytes'] / 1024"

A later value that does not fit the fixed type is left as text and named in a babysoarus_autocast_failed field, so the row is still returned and the problem is visible rather than silent:

n     babysoarus_autocast_failed
5
n/a   n

A field whose first batch was text stays text, and values that later look numeric are not reported: keeping them as text is the correct outcome, not a failure.

Typing Against a Data Model

autocast infers a type from the value. A data model declares it, which resolves cases inference cannot:

index=_audit
| exec inline="
obj = datamodel('internal_audit_logs').object('Audit.searches')
obj.conform(events)
for e in events:
    e['slow'] = e['total_run_time'] > 10"

conform() coerces every field the model declares to its declared type, in place. Fields the model does not name are left alone, because a batch usually carries more than the model describes.

Because the model has stated the type, values that autocast deliberately leaves as text become what the schema says they are: 1 is a boolean where the model says boolean, 1755300000 is a timestamp where it says timestamp, and 007 is the number 7 where it says number.

When a Value Does Not Fit

The value is kept exactly as it arrived and named in a babysoarus_conformance field:

total_run_time  is_realtime  babysoarus_conformance
not-a-number    perhaps      total_run_time:expected number,is_realtime:expected boolean

Losing your data to make a schema look satisfied would be the wrong trade, so nothing is dropped or replaced. A required field that never arrived is reported as missing rather than invented.

Inspecting a Model

m = datamodel('internal_audit_logs')
m.objects                      # ['Audit', 'Audit.modify', 'Audit.searches']
obj = m.object('Audit.searches')
obj.fields['action'].type      # 'string'
obj.required                   # field names the model marks required
obj.spl()                      # | from datamodel:"internal_audit_logs.Audit.searches"

Definitions are fetched once per search and reused, so calling datamodel() inside a per-event loop costs one request rather than one per event.

Calculated fields are part of the schema. Splunk has already evaluated them by the time a batch arrives, so what matters here is their declared type, not the order they were calculated in.

Fields Have to Be Materialised First

This one surprises everybody once.

Splunk only materialises search-time extracted fields that something in the pipeline actually references. Your snippet is opaque to Splunk, so it has no way of knowing that you are about to read e['action'], and the field arrives missing.

# `action` is extracted from _raw at search time, and nothing mentions it,
# so the snippet sees nothing.
index=auth | exec inline="for e in events: e['x'] = e.get('action', 'MISSING')"

Name the fields you need before the exec:

index=auth
| fields _time user src_ip dest action
| exec inline="for e in events: e['x'] = e['action']"

Or take everything, which is simpler and more expensive:

index=auth | fields + * | exec inline="..."

This does not apply after stats, table, eval or anything else that has already materialised the fields it produces, which is why most examples do not need it. It applies when a snippet reads extracted fields straight off raw events.

If a field is unexpectedly missing, this is almost always why.

The Standard Library

The interpreter is CPython 3.14, so everything the language offers up to that version is available: structural pattern matching, the walrus operator, f-strings, dataclasses, zoneinfo. You are not writing to an older dialect.

The version does not follow whichever Python you used to build the app. It is fixed by the interpreter that componentize-py embeds, and it is the same on every search head running the same build.

Most of the standard library is available and importable as usual:

| exec inline="import csv, hashlib, base64, ipaddress, statistics, urllib.parse
for e in events:
    e['sha'] = hashlib.sha256(e['raw'].encode()).hexdigest()
    e['is_private'] = ipaddress.ip_address(e['src_ip']).is_private"

csv, hashlib, base64, binascii, bisect, calendar, codecs, collections, copy, datetime, decimal, difflib, fnmatch, fractions, functools, gzip, heapq, hmac, html, io, ipaddress, itertools, json, math, operator, os.path, pathlib, pprint, queue, random, re, secrets, shlex, statistics, string, struct, textwrap, time, types, typing, unicodedata, urllib.parse, uuid, xml.etree.ElementTree and zlib are all present.

Modules must be baked in at build time. The interpreter is a WebAssembly component whose imports are resolved when it is built, so import at search time can only find modules that were imported when the component was compiled. Importing anything else raises ModuleNotFoundError. To add one, see Dependencies.

Calling Out

http_request returns a dictionary. The body is bytes, so decode it or hand it to json.loads, which accepts either.

r = http_request(
    'POST',
    'https://intel.example.internal/api/v2/lookup',
    headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'},
    body={'indicators': [e['dest_host'] for e in events]},   # a dict is JSON-encoded
    timeout=10,
)
if r['status'] != 200:
    raise BabysoarusError(f"intel API returned {r['status']}")
verdicts = json.loads(r['body'])

Call it once per batch, outside the for loop, wherever the API supports it. One request carrying 32 indicators beats 32 requests carrying one.

splunk_request fills in the credentials and base URL for you:

r = splunk_request('GET', '/services/authentication/current-context?output_mode=json')

Guest HTTP honours the daemon's proxy environment, except for loopback addresses, which are never proxied. Certificates are fully verified, except for Splunk's own management endpoint on loopback, whose default certificate is self-signed. See Security model.

The Splunk SDK

splunk_request is deliberately low-level. For anything beyond a call or two, install the official Splunk SDK and use service, which is already authenticated for the current search:

{ "packages": [{ "name": "splunk-sdk", "version": "3.0.0" }] }

Put that in config/packages.json (see Dependencies), restart the daemon, and service works:

| makeresults
| exec inline="
for e in events:
    e['saved_searches'] = [s.name for s in service.saved_searches]
    e['version'] = service.info['version']"

service is a normal splunklib.client.Service, so the SDK's own documentation applies: service.saved_searches, service.kvstore, service.jobs, service.apps and the rest.

Three things are worth knowing about it.

It costs nothing until you touch it. The object in scope is a placeholder; the SDK is imported and the connection prepared on first attribute access. A snippet that never mentions service pays nothing, and a deployment without the SDK installed still runs every other snippet. Reaching for it without the SDK gives you an error saying exactly what to add and where.

It never holds your credentials. The SDK's HTTP handler is replaced with one that routes through the host, which resolves the endpoint against this search's base URL and attaches the credentials on the far side of the sandbox boundary. service.token reads <injected by the BabySOARus host>, because that is genuinely all the sandbox knows. Snippet code cannot leak a Splunk token it was never given, nor point the SDK at a different server.

It talks to this Splunk instance only. For anything else, use http_request, which is subject to the network policy.

search() wraps the SDK for the common case: run some SPL, get rows back.

| makeresults
| exec inline="
rows = search('index=_internal | head 100 | stats count by sourcetype', earliest='-1h')
for e in events:
    e['sourcetypes'] = len(rows)"

It returns a list of dictionaries. Three of its defaults are opinions worth knowing:

  • The time range defaults to the last 24 hours. The SDK applies no default at all, and an accidental all-time search launched from inside another search is a good way to hurt a search head. Pass earliest=None if you mean it.
  • Results are capped at 10,000 rows, for the same reason. Pass cap=None to lift it.
  • A leading search is added if your SPL does not start with a command, so search('index=main') works rather than failing on syntax.

mode='export' streams instead of buffering, for result sets too large to hold in memory. mode='job' creates a job and waits for it.

The KV Store

kv(name) gives a collection with the paging and chunking handled:

| makeresults
| exec inline="
watchlist = kv('threat_hosts')
watchlist.batch_save([{'host': h, 'score': 90} for h in ('a.example', 'b.example')])
hits = watchlist.find(query={'score': {'\$gte': 80}})
for e in events:
    e['high_risk'] = len(hits)"
MethodDoes
get(key)One document by _key, or None if absent.
find(query=, sort=, limit=, skip=, fields=)Documents matching a MongoDB-style query.
insert(doc)Adds one, returns its _key.
update(key, doc)Replaces one.
delete(key=) / delete(query=) / delete()Removes one, some, or all.
batch_save(docs)Saves many.

Two limits are handled for you, because Splunk enforces them and the SDK does not mention them. A query returns at most 50,000 rows per call, so find() pages until it has everything. Without that, a collection of 60,000 documents returns 50,000 and looks complete. And batch_save accepts 1,000 documents per call, so it chunks; exceeding that limit is an error rather than a truncation.

The collection must exist. Declare it in collections.conf, or create it once with service.kvstore.create('name').

Reading Lookups

Lookup directories are mounted read/write inside the sandbox:

| exec inline="import csv
with open('/lookups/apps/search/asset_owners.csv') as handle:
    owners = {row['ip']: row['owner'] for row in csv.DictReader(handle)}
for e in events:
    e['owner'] = owners.get(e['src_ip'], 'unassigned')"

Load the file once per search by guarding it, as with seen above, rather than re-reading it on every batch.

lookups_dirs() returns the mounted paths, which are /lookups/system and /lookups/apps/<app> for every app that has a lookups directory.

Errors

Raise BabysoarusError for a message you want an analyst to read:

if r['status'] == 429:
    raise BabysoarusError('threat intel API rate limit reached; reduce the time range')

Any other exception is reported too, with its type and message. What reaches Splunk describes your snippet, not the internals of the executor.

An error fails the current batch. Earlier batches in the same search have already been emitted, which is what streaming means.

Performance

The sandbox boundary is cheap relative to the work most snippets do, so for most searches your logic is the cost rather than the boundary.

Three habits keep it that way:

Hoist work out of the loop. Compiling a regular expression, parsing a lookup, or calling an API belongs outside for e in events.

Raise batch_size for expensive setup. batch_size=512 amortises per-batch work over more events. Lower it towards 1 for the fastest possible first result.

Compile the hot path. If a snippet is stable and the search is enormous, a custom function is faster by an order of magnitude or more -- make bench reports the exact factor for your hardware.

Limits

  • 30 seconds of execution per micro-batch, then the guest is interrupted. Tune with BABYSOARUS_TIMEOUT_EXECUTE_SECS.
  • No sockets, no subprocesses, no import socket.
  • No filesystem beyond the mounted lookup directories.
  • No environment variables.
  • 64 MB cap on an HTTP response body.