Skip to content

Commit 3e3508a

Browse files
authored
Add example AI Custom Search app (#61)
* Add initial custom search app code * Formatting * Add nice logging * Remove folding region indicator * Last PR fixes
1 parent 71ffc60 commit 3e3508a

6 files changed

Lines changed: 217 additions & 5 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ docker-refresh: docker-remove docker-start
7373

7474
.PHONY: docker-splunk-restart
7575
docker-splunk-restart:
76-
docker exec -it splunk sh -c '/opt/splunk/bin/splunk restart'
76+
docker exec -it splunk sudo sh -c '/opt/splunk/bin/splunk restart --run-as-root'
7777

7878
.PHONY: docker-tail-python-log
7979
docker-tail-python-log:
80-
docker exec splunk tail /opt/splunk/var/log/splunk/python.log
80+
docker exec splunk sudo tail /opt/splunk/var/log/splunk/python.log

docker-compose.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
services:
22
splunk:
3+
container_name: splunk
4+
platform: linux/amd64
35
build:
46
context: .
57
dockerfile: Dockerfile
6-
platform: linux/amd64
7-
container_name: splunk
88
environment:
99
- SPLUNK_START_ARGS=--accept-license
1010
- SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com
@@ -16,7 +16,7 @@ services:
1616
- "8088:8088"
1717
- "8089:8089"
1818
healthcheck:
19-
test: ['CMD', 'curl', '-f', 'http://localhost:8000']
19+
test: ["CMD", "curl", "-f", "http://localhost:8000"]
2020
interval: 5s
2121
timeout: 5s
2222
retries: 20
@@ -29,12 +29,17 @@ services:
2929
- "./tests/system/test_apps/cre_app:/opt/splunk/etc/apps/cre_app"
3030
- "./tests/system/test_apps/ai_agentic_test_app:/opt/splunk/etc/apps/ai_agentic_test_app"
3131
- "./tests/system/test_apps/ai_agentic_test_local_tools_app:/opt/splunk/etc/apps/ai_agentic_test_local_tools_app"
32+
33+
- "./examples/ai_custom_search_app:/opt/splunk/etc/apps/ai_custom_search_app"
34+
3235
- "./splunklib:/opt/splunk/etc/apps/eventing_app/bin/splunklib"
3336
- "./splunklib:/opt/splunk/etc/apps/generating_app/bin/splunklib"
3437
- "./splunklib:/opt/splunk/etc/apps/reporting_app/bin/splunklib"
3538
- "./splunklib:/opt/splunk/etc/apps/streaming_app/bin/splunklib"
3639
- "./splunklib:/opt/splunk/etc/apps/modularinput_app/bin/splunklib"
3740
- "./splunklib:/opt/splunk/etc/apps/ai_agentic_test_app/bin/lib/splunklib"
3841
- "./splunklib:/opt/splunk/etc/apps/ai_agentic_test_local_tools_app/bin/lib/splunklib"
42+
- "./splunklib:/opt/splunk/etc/apps/ai_custom_search_app/bin/lib/splunklib"
43+
3944
- "./tests:/opt/splunk/etc/apps/ai_agentic_test_app/bin/lib/tests"
4045
- "./tests:/opt/splunk/etc/apps/ai_agentic_test_local_tools_app/bin/lib/tests"
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Copyright © 2011-2026 Splunk, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
import asyncio
15+
import json
16+
import logging
17+
import logging.handlers
18+
import os
19+
import sys
20+
from collections.abc import Generator, Sequence
21+
from typing import Any, final, override
22+
23+
# ! NOTE: This insert is only needed for splunk-sdk-python CI/CD to work.
24+
# ! Remove this if you're modifying this example locally.
25+
sys.path.insert(0, "/splunklib-deps")
26+
27+
# Include all 3rd party dependencies from <app_name>/bin/lib/
28+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "lib"))
29+
30+
import httpx
31+
from pydantic import BaseModel, Field
32+
33+
from splunklib.ai import OpenAIModel
34+
from splunklib.ai.agent import Agent
35+
from splunklib.ai.messages import HumanMessage
36+
from splunklib.data import Record
37+
from splunklib.searchcommands import (
38+
Configuration,
39+
Option,
40+
dispatch, # pyright: ignore[reportPrivateLocalImportUsage]
41+
validators,
42+
)
43+
from splunklib.searchcommands.eventing_command import EventingCommand
44+
45+
# BUG: By default, a CRE process has its trust store path overridden by Splunk.
46+
# Unsetting that env makes said process use the default CAs instead.
47+
CA_TRUST_STORE = "/opt/splunk/openssl/cert.pem"
48+
if os.environ.get("SSL_CERT_FILE") == CA_TRUST_STORE and not os.path.exists(
49+
CA_TRUST_STORE
50+
):
51+
del os.environ["SSL_CERT_FILE"]
52+
53+
APP_NAME = "ai_custom_search_app"
54+
55+
56+
def setup_logging() -> logging.Logger:
57+
"""To see logs from this logger, run this SPL in Splunk:
58+
`index=_internal sourcetype=ai_custom_search_app:log`
59+
"""
60+
SPLUNK_HOME: str = os.environ.get("SPLUNK_HOME", os.path.join("/opt", "splunk"))
61+
LOG_FILE: str = os.path.join(SPLUNK_HOME, "var", "log", "splunk", f"{APP_NAME}.log")
62+
63+
logger = logging.getLogger(APP_NAME)
64+
logger.setLevel(logging.DEBUG)
65+
66+
handler = logging.handlers.RotatingFileHandler(
67+
LOG_FILE, maxBytes=1024 * 1024, backupCount=5
68+
)
69+
handler.setFormatter(
70+
logging.Formatter(f"%(asctime)s %(levelname)s [{APP_NAME}] %(message)s")
71+
)
72+
logger.addHandler(handler)
73+
74+
return logger
75+
76+
77+
logger = setup_logging()
78+
79+
# endregion
80+
81+
LLM_MODEL = OpenAIModel(
82+
model="gpt-4o-mini",
83+
base_url="https://api.openai.com/v1",
84+
# To store API keys, consider secret storage:
85+
# https://dev.splunk.com/enterprise/docs/developapps/manageknowledge/secretstorage/secretstoragepython
86+
api_key="<super_secret_key>",
87+
)
88+
LLM_SYSTEM_PROMPT = "You are an Expert Splunk Data Analyst."
89+
90+
91+
class AgentOutput(BaseModel):
92+
"""Output schema model for the LLM-based Agent."""
93+
94+
should_keep: bool = Field(
95+
description="If False, filter a record out of the pipeline.", default=True
96+
)
97+
is_relevant: bool = Field(
98+
description="Should event be highlighted in a table view.", default=False
99+
)
100+
101+
102+
@final
103+
@Configuration()
104+
class AgenticReportingCSC(EventingCommand):
105+
"""agenticreport provides an assortment of example integrations with an LLM Agent.
106+
107+
Example:
108+
```
109+
| makeresults count=10 | streamstats count as _row
110+
| agenticreport should_filter="true" highlight_topic="Is this record's _row odd?"
111+
```
112+
"""
113+
114+
should_filter = Option(
115+
doc="Should irrelevant records be filtered out",
116+
require=False,
117+
default=False,
118+
validate=validators.Boolean(),
119+
)
120+
highlight_topic = Option(
121+
doc="What to consider when deciding to highlight a record",
122+
require=False,
123+
default=False,
124+
)
125+
126+
@override
127+
def transform(self, records: Sequence[Record]) -> Generator[Record, Any]:
128+
logger.info(
129+
"Begin transform() in `agenticreport` with "
130+
+ f"options: {self.should_filter=}, {self.highlight_topic=}"
131+
)
132+
133+
for record in records:
134+
if not record:
135+
continue
136+
137+
record_json = json.dumps(record)
138+
logger.debug(f"{record_json=}")
139+
140+
user_prompt = f"""
141+
Analyze this log: "{record_json}" and perform these tasks:
142+
143+
1. Decide if record matches the intent: "{self.should_filter}"?
144+
(Return boolean `should_keep`)
145+
2. Is this log relevant to "{self.highlight_topic}"?
146+
(Return boolean `is_relevant`)
147+
"""
148+
try:
149+
llm_analysis = asyncio.run(self.invoke_agent(user_prompt))
150+
logger.debug(f"{llm_analysis.model_dump_json()=}")
151+
if self.should_filter and not llm_analysis.should_keep:
152+
# Filter the record out of the results
153+
continue
154+
155+
if self.highlight_topic:
156+
self.add_field(record, "should_keep", llm_analysis.is_relevant)
157+
except Exception as e:
158+
logger.exception(e)
159+
self.add_field(record, "agent_error", e)
160+
finally:
161+
yield record
162+
163+
logger.debug("Finish transform() in `agenticreport`")
164+
165+
async def invoke_agent(self, prompt: str) -> AgentOutput:
166+
assert self.service, "No Splunk connection available"
167+
168+
async with Agent(
169+
model=LLM_MODEL,
170+
system_prompt=LLM_SYSTEM_PROMPT,
171+
service=self.service,
172+
output_schema=AgentOutput,
173+
) as agent:
174+
logger.info(f"Invoking {LLM_MODEL.model} at {LLM_MODEL.base_url}")
175+
result = await agent.invoke([HumanMessage(role="user", content=prompt)])
176+
return result.structured_output
177+
178+
179+
dispatch(AgenticReportingCSC, sys.argv, sys.stdin, sys.stdout, __name__)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[id]
2+
name = ai_custom_search_app
3+
version = 0.1.0
4+
5+
[package]
6+
id = ai_custom_search_app
7+
check_for_updates = False
8+
9+
[install]
10+
is_configured = 0
11+
state = enabled
12+
13+
[ui]
14+
is_visible = 1
15+
label = [EXAMPLE] AI Custom Search Command App
16+
17+
[launcher]
18+
description = Perform custom operations on search results
19+
version = 0.1.0
20+
author = Splunk
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[agenticreport]
2+
filename = agentic_reporting_csc.py
3+
chunked = true
4+
python.version = python3
5+
python.required = 3.13
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[monitor://$SPLUNK_HOME/var/log/splunk/ai_custom_search_app.log]
2+
index = _internal
3+
sourcetype = ai_custom_search_app:log

0 commit comments

Comments
 (0)