Building Dynamic Saved Search URLs in OpenSearch Dashboards (with Python)
I was recently trying to figure out a clean way to build dynamic URLs for saved searches in OpenSearch Dashboards. The idea was simple:
- Use one fixed saved search (columns, index, time range, etc.).
- Pass a dynamic filter in the URL, so every alert or script can jump straight to matching logs.
But in practice I broke the URL many times with quotes, spaces, and Python string issues, before I finally got a simple and reliable pattern working.
In this post I will explain it in two parts:
- How to build the full URL manually by combining the saved search URL and a dynamic query.
- How to do the same in Python, including proper URL encoding so the links do not break when you add spaces and special characters.
For all examples I will use a demo domain:
https://opensearch.rootsaid.com/dashboards/...
1) Building the URL Manually: Saved Search + Dynamic Query
When you create and save a search in OpenSearch Dashboards (Discover or Data Explorer), the UI gives it an internal ID. That ID appears at the end of the URL.
A typical saved search URL looks like this:
https://opensearch.rootsaid.com/dashboards/app/data-explorer/discover#/view/12345678-abcd-ef01-2345-6789abcdef01
If you open this URL, it will show whatever was saved: index pattern, selected columns, default query, etc.
OpenSearch also allows you to pass a query in the URL itself, using a parameter named _q. That parameter normally looks something like this:
?_q=(query:(language:kuery,query:'source_ip:10.0.0.1'))
So the full URL becomes:
https://opensearch.rootsaid.com/dashboards/app/data-explorer/discover#/view/12345678-abcd-ef01-2345-6789abcdef01?_q=(query:(language:kuery,query:'source_ip:10.0.0.1'))
The important parts to notice:
- _q controls the query state.
- language:kuery tells OpenSearch that we are using the KQL style query.
- query:’source_ip:10.0.0.1′ is exactly what you would type into the search bar in the UI.
Example: Multiple Conditions
Now imagine we want to build a URL that filters on three fields:
- source_ip
- destination_ip
- destination_port
In the OpenSearch search bar, we might type:
(source_ip:10.0.0.1 AND destination_ip:192.168.1.50 AND destination_port:443)
Inside the _q parameter, that looks like this:
?_q=(query:(language:kuery,query:'(source_ip:10.0.0.1 AND destination_ip:192.168.1.50 AND destination_port:443)'))
Then the full URL becomes:
https://opensearch.rootsaid.com/dashboards/app/data-explorer/discover#/view/12345678-abcd-ef01-2345-6789abcdef01?_q=(query:(language:kuery,query:'(source_ip:10.0.0.1 AND destination_ip:192.168.1.50 AND destination_port:443)'))
If you paste that into the browser, the browser will usually auto encode the spaces as %20 and it will work.
But this is where things start to get tricky when you move to code.
- URLs should not contain raw spaces.
- If your query is long or contains special characters, it is easy to break the URL.
- Copy pasting it manually works, but in scripts and alerts you need something more robust.
That is what we solve in part 2 with Python.
2) Generating Dynamic URLs in Python (with URL Encoding)
Now let us build this properly with Python. The goal is:
- Start from a fixed saved search URL.
- Build a query string based on values from an event or alert.
- URL encode that query so spaces and special characters do not break the link.
- Return a ready to use OpenSearch Dashboards URL.
We will use the standard library function urllib.parse.quote.
Base Saved Search URL
First, keep your saved search URL as a constant:
from urllib.parse import quote
SAVED_SEARCH_URL = (
"https://opensearch.rootsaid.com/dashboards/app/data-explorer/discover#"
"/view/12345678-abcd-ef01-2345-6789abcdef01"
)
This URL does not change. All the dynamic behavior will come from what we add after _q.
Example Event Object
Imagine you have an event that looks like this (for example from a correlation engine or a Lambda function):
event = {
"source_ip": "10.0.0.1",
"destination_ip": "192.168.1.50",
"destination_port": "443",
}
We want to build a URL that opens the saved search and automatically filters to this combination of values.
Step 1: Build the KQL Query String
First, we extract the fields and build the same query we would type in the Discover search bar:
src_ip = event.get("source_ip", "N/A")
dst_ip = event.get("destination_ip", "N/A")
dst_port = event.get("destination_port", "N/A")
kql_query = (
f"(source_ip:{src_ip} AND destination_ip:{dst_ip} AND destination_port:{dst_port})"
)
At this point kql_query contains:
(source_ip:10.0.0.1 AND destination_ip:192.168.1.50 AND destination_port:443)
This is a normal Python string with spaces, parentheses, and so on.
Step 2: URL Encode the Query
Now we need to prepare this string to be placed inside a URL parameter. This is where urllib.parse.quote comes in.
encoded_kql = quote(kql_query, safe="():\"=<>!-*")
A couple of notes:
- quote will convert spaces to %20 and encode other special characters.
- The safe argument tells quote which characters we want to keep as they are.
- You can adjust safe depending on how readable you want the URL to be, but keeping at least spaces encoded is important.
After this step, encoded_kql will be a URL safe version of the query. For example:
(source_ip:10.0.0.1%20AND%20destination_ip:192.168.1.50%20AND%20destination_port:443)
(Depending on the safe characters, some extra symbols may also be encoded.)
Step 3: Wrap It in the _q Parameter
Now we plug encoded_kql into the _q structure:
dynamic_query = f"?_q=(query:(language:kuery,query:'{encoded_kql}'))"
A couple of details that help avoid bugs:
- Keep the KQL query inside single quotes after query:.
- Build the KQL first, then encode, and only then embed it into the URL. This makes the code easier to read and debug.
Step 4: Combine Everything into the Final URL
Finally, we append the dynamic query part to the base saved search URL:
dynamic_url = SAVED_SEARCH_URL + dynamic_query
print(dynamic_url)
This dynamic_url is now a full OpenSearch Dashboards link that opens your saved search and applies the dynamic filter.
Full Example Function
Here is the full example as a reusable function that takes an event dictionary and returns the final URL:
from urllib.parse import quote
SAVED_SEARCH_URL = (
"https://opensearch.rootsaid.com/dashboards/app/data-explorer/discover#"
"/view/12345678-abcd-ef01-2345-6789abcdef01"
)
def build_opensearch_url_from_event(event: dict) -> str:
"""
Build a dynamic OpenSearch Dashboards URL pointing to a saved search,
with filters based on source_ip, destination_ip, and destination_port.
"""
# 1. Extract values safely from the event
src_ip = event.get("source_ip", "N/A")
dst_ip = event.get("destination_ip", "N/A")
dst_port = event.get("destination_port", "N/A")
# 2. Build the KQL query as if you typed it in the UI
kql_query = (
f"(source_ip:{src_ip} AND destination_ip:{dst_ip} AND destination_port:{dst_port})"
)
# 3. URL encode the KQL so spaces and special characters do not break the URL
encoded_kql = quote(kql_query, safe="():\"=<>!-*")
# 4. Build the _q parameter
dynamic_query = f"?_q=(query:(language:kuery,query:'{encoded_kql}'))"
# 5. Combine the base URL with the dynamic query
dynamic_url = SAVED_SEARCH_URL + dynamic_query
return dynamic_url
if __name__ == "__main__":
sample_event = {
"source_ip": "10.0.0.1",
"destination_ip": "192.168.1.50",
"destination_port": "443",
}
url = build_opensearch_url_from_event(sample_event)
print("Generated OpenSearch URL:")
print(url)
You can adjust this function easily:
- Replace source_ip, destination_ip, destination_port with whatever fields are relevant to your use case.
- Change language:kuery to language:lucene if you prefer Lucene syntax.
- Plug it into your alerting pipeline, Lambda, or any other place where you want to provide a deep link to the logs.
Final Notes and Practical Tips
While working this out, these were the common mistakes that kept hitting me:
- Forgetting to encode the query, which sometimes worked in the browser but broke when used by other tools.
- Putting too much logic inside one giant f string, which made quoting errors very hard to see.
- Mixing single and double quotes inside f strings in a messy way.
The approach that finally made everything stable was:
- Keep the saved search URL as a fixed constant.
- Build the KQL query as a normal Python string.
- URL encode that query separately.
- Insert the encoded query into the _q parameter.
- Concatenate it with the base URL.
Once you follow that pattern, building dynamic OpenSearch saved search URLs becomes straightforward, and you can reuse the same idea for many different fields and use cases.
