Learn CQL Fundamentals
Write CQL queries to filter, aggregate, and visualize log data in LogScale.
Introduction
30 Second Summary
Every time you visit a website, swipe your badge, or log into an app, that action creates a log entry. Security teams sift through millions of these entries daily to spot threats before they become breaches.
In this project, you will learn CrowdStrike Query Language (CQL) by writing progressively complex queries in a real log analysis environment. You will filter events, extract fields, aggregate data, and build visualizations that mirror real-world security operations.
What You'll Build
A hands-on CQL query workbook built inside Falcon LogScale Community Edition, where you will write and save a collection of queries covering the core patterns used by security analysts and threat hunters.
By the end of this project, you'll have:
- A configured Falcon LogScale Community Edition account with sample log data loaded and ready to query.
- A series of saved CQL queries covering filtering, regex extraction, aggregation, conditional logic, and visualization.
- A personal CQL cheat sheet of reusable patterns ready for real threat-hunting scenarios.
- Secret Mission: Build a threat-hunting query that detects anomalous login patterns and visualizes them on a world map.
Are there any prerequisites?
None. This project includes full setup for all required tools. You just need a web browser and an internet connection.
Set Up Your Falcon LogScale Account
Before you can write any queries, you need an environment to run them in. Falcon LogScale Community Edition gives you the full production CQL query engine for free.
In this step, you will create an account, set up a practice repository, and load sample data so you have something to query in the steps ahead.
In this step, get ready to:
- Sign up for a free Falcon LogScale Community Edition account.
- Create a repository for your CQL practice queries.
- Ingest sample log data and verify it appears in your repository.
Sign up for Falcon LogScale Community Edition
Falcon LogScale Community Edition is a free cloud-hosted version of the same query platform used in production CrowdStrike environments. It requires no credit card and has no trial period.
- Open your web browser and navigate to cloud.community.humio.com.
- Click the sign-in button for your preferred provider: Google, GitHub, or Bitbucket.
- Authorize the login when prompted by your chosen provider.
- Once authorized, LogScale will redirect you to the home page showing available repositories and informational panels.
What is Falcon LogScale Community Edition?
Falcon LogScale Community Edition is a free tier of CrowdStrike's log management platform. It provides 16 GB/day data ingest, 7-day data retention, and supports up to 5 users.
CQL skills you learn here transfer directly to paid Falcon LogScale, Next-Gen SIEM, and Falcon LTR environments.
Not seeing the login page?
Make sure you are navigating to cloud.community.humio.com and not a different CrowdStrike URL. Account approval may take a couple of days in some cases. If your login provider asks for additional permissions, accept them to proceed.
Still stuck? Help me troubleshoot my LogScale Community Edition login.
Create a repository for your CQL practice
A repository in LogScale is a container that stores your log events. All of your queries in this project will run against a single repository.
- On the LogScale home page, click the Repository and Views tab.
- Click + Add new and select the repository icon.
- In the Name field, enter cql-practice.
- Leave the Description and Data Retention fields at their defaults.
- Click the button to create the repository.
You should now see cql-practice listed in your repositories.
Why create a separate repository?
LogScale queries always target a single repository. Creating a dedicated cql-practice repository keeps your learning data isolated and makes cleanup easy when you are done.
Repository name rejected?
LogScale does not allow repository names that start with sandbox or humio. Make sure you typed cql-practice exactly.
Need more help? Help me fix my repository creation issue in LogScale.
Ingest sample log data
An empty repository has nothing to query. You need to load sample events so you have real data to practice your CQL queries against.
- Open your cql-practice repository by clicking its name in the repository list.
- Click Settings in the top menu.
- Under Ingest in the side menu, click Ingest tokens.
- Click + Add token to create a new ingest token.
- Name the token sample-data and click Save.
- Copy the token string that appears. You will use it in the next command.
Now send sample structured events to your repository using the Ingest API. Open a terminal and run this command:
curl -X POST https://cloud.community.humio.com/api/v1/ingest/humio-structured \
-H "Authorization: Bearer your-ingest-token-here" \
-H "Content-Type: application/json" \
-d '[
{
"tags": {"source": "sample"},
"events": [
{"timestamp": "2026-06-07T10:00:00Z", "attributes": {"user": "alice", "action": "login", "statuscode": "200", "url": "/dashboard", "ip": "192.168.1.10"}},
{"timestamp": "2026-06-07T10:01:00Z", "attributes": {"user": "bob", "action": "login", "statuscode": "401", "url": "/admin", "ip": "10.0.0.5"}},
{"timestamp": "2026-06-07T10:02:00Z", "attributes": {"user": "alice", "action": "search", "statuscode": "200", "url": "/api/search?q=logs", "ip": "192.168.1.10"}},
{"timestamp": "2026-06-07T10:03:00Z", "attributes": {"user": "charlie", "action": "login", "statuscode": "200", "url": "/dashboard", "ip": "172.16.0.22"}},
{"timestamp": "2026-06-07T10:04:00Z", "attributes": {"user": "bob", "action": "login", "statuscode": "200", "url": "/dashboard", "ip": "10.0.0.5"}},
{"timestamp": "2026-06-07T10:05:00Z", "attributes": {"user": "alice", "action": "upload", "statuscode": "500", "url": "/api/upload", "ip": "192.168.1.10"}},
{"timestamp": "2026-06-07T10:06:00Z", "attributes": {"user": "dave", "action": "login", "statuscode": "404", "url": "/login", "ip": "203.0.113.45"}},
{"timestamp": "2026-06-07T10:07:00Z", "attributes": {"user": "charlie", "action": "download", "statuscode": "200", "url": "/files/report.pdf", "ip": "172.16.0.22"}},
{"timestamp": "2026-06-07T10:08:00Z", "attributes": {"user": "eve", "action": "login", "statuscode": "403", "url": "/admin", "ip": "198.51.100.7"}},
{"timestamp": "2026-06-07T10:09:00Z", "attributes": {"user": "alice", "action": "logout", "statuscode": "200", "url": "/logout", "ip": "192.168.1.10"}}
]
}
]'
- Replace your-ingest-token-here with the token string you copied in the previous step.
What does this command do?
This curl command sends 10 structured JSON events to the LogScale Ingest API. Each event includes a timestamp and attributes like user, action, statuscode, url, and ip.
These fields give you a realistic mini-dataset that simulates web application access logs. You will filter, aggregate, and visualize these events throughout the project.
Now verify your data arrived. Navigate back to your cql-practice repository's search page.
- Click the repository name cql-practice in the top navigation to return to the query view.
- In the query bar at the top, type login and press Enter.
- Set the time range selector to cover the last 24 hours.
You should see events appear in the results list showing login actions from multiple users.
No events showing up?
Double-check that you pasted your ingest token correctly in the curl command. Make sure the time range selector is set wide enough to include today's date. LogScale is case-sensitive, so type login in lowercase.
Still no results? Help me troubleshoot why my ingested data is not appearing in LogScale.
Your environment is ready with live data. Next up, you will write your first CQL queries to filter and explore these events.
Master Basic Filters and Operators
Your repository has sample data loaded and ready to query. Now it is time to learn the core skill of any security analyst: filtering log data to find exactly what you need.
CQL gives you multiple ways to search events, from simple keyword searches to precise field-level filters. In this step, you will build progressively complex queries that narrow thousands of events down to the handful that matter.
In this step, get ready to:
- Write free-text and field filter queries using wildcards, negation, and existence checks.
- Combine filters with logical operators and parenthetical grouping.
- Chain multiple query stages together with the pipe operator and add inline comments.
Write free-text and field filter queries
The simplest way to search in CQL is a free-text filter. You type a word and LogScale searches across all fields in every event for a match.
- Return to the Search page of your cql-practice repository.
- Clear any existing text in the Query Editor at the top of the page.
- Type the following query into the Query Editor and click Run (or press Enter):
error
You should see the Event list panel filter down to only events containing the word error in any field.
What is a free-text filter?
A free-text filter searches all fields in your events except @id, @timestamp, @ingesttimestamp, and tag fields. LogScale treats free-text as if it has a leading and trailing wildcard automatically.
This makes it the fastest way to do an initial broad search, similar to grep on a Unix command line.
Now try field-specific filters. Clear the Query Editor and type each of the following queries one at a time, clicking Run after each:
statuscode = 404
url = *login*
user != admin
user = *
What do these field filters do?
- statuscode = 404 matches events where the statuscode field equals exactly 404.
- url = *login* uses wildcards to find events where the url field contains the word login anywhere.
- user != admin uses negation to exclude events where the user field equals admin.
- user = * is an existence check. It returns only events that have a user field, regardless of value.
After running each query, check the Event list to confirm results appear that match your filter criteria.
Seeing zero results?
Your sample data may use different field names than these examples. Click in the left-hand field panel to see available fields in your data. Substitute a field name that exists in your dataset (for example, status instead of statuscode).
Also confirm that the Time interval selector at the top right covers the period when your data was ingested.
Help me figure out why my CQL query returns no results.
Combine filters with logical operators
Real-world log analysis rarely uses a single filter. You need to combine conditions to zero in on specific event patterns.
- Clear the Query Editor and type the following query, then click Run:
statuscode=404 OR statuscode=500
- Now try combining multiple filters by typing this query and clicking Run:
statuscode=404 url = *login*
- Try negation by running this query:
NOT statuscode=200
- Finally, use parentheses to group expressions. Run this query:
(statuscode=404 OR statuscode=500) url = *login*
How do logical operators work in CQL?
- Space-separated filters imply AND. Writing statuscode=404 url = *login* means both conditions must be true.
- OR matches events that satisfy either condition.
- NOT excludes events matching the condition.
- Parentheses group expressions to control evaluation order. This matters because OR has higher precedence than AND in CQL.
Why does OR have higher precedence than AND?
In most programming languages, AND binds tighter than OR. CQL flips this convention. Without parentheses, a OR b c evaluates as (a OR b) AND c, not a OR (b AND c).
Always use parentheses when mixing AND and OR to make your intent clear and avoid unexpected results.
Check the Event list after each query. The parenthetical query should return only events with status 404 or 500 that also have login in the URL.
Getting unexpected results with OR?
If your combined query returns too many or too few results, check whether you need parentheses. Because OR has higher precedence than AND in CQL, forgetting parentheses changes which conditions group together.
Help me understand why my CQL query with OR and AND returns unexpected results.
Use the pipe operator to chain commands
CQL's pipe-based architecture passes results from one expression to the next, just like Unix shell pipes. This lets you build multi-stage queries where each stage refines the previous output.
- Clear the Query Editor and type this multi-stage query, then click Run:
error | statuscode >= 400 // Only server and client errors
What does this query do?
- error is a free-text filter that finds all events containing the word "error" in any field.
- | (pipe) passes those results to the next expression.
- statuscode >= 400 further narrows results to only events with status codes 400 or above.
- // starts an inline comment. Everything after it on that line is ignored by LogScale. Use comments to document what your queries do.
- Try another piped query to see how stages stack. Run this:
url = *login* | statuscode != 200 // Failed login page requests
You should see only events where the URL contains login and the status code is anything other than 200.
Use comments to build a query library
As you build more complex queries, add // comments explaining the purpose of each stage. When you save queries later (Step 5), these comments become documentation for your team. CQL also supports multi-line comments with /* ... */.
Pipe not narrowing results?
Make sure there are no extra spaces around the | character. Also check that the field name after the pipe matches your dataset. Look at the left-hand field panel to verify available field names.
Help me debug my piped CQL query that isn't filtering as expected.
You now have the building blocks for any CQL query: free-text search, field filters, logical operators, and piped stages. Next up, you will use regular expressions and the assignment operator to extract and transform data on the fly.
Extract and Transform Data with Regex
You now have a solid foundation in CQL filtering. You can find events by text, match specific field values, combine conditions with logical operators, and chain commands with pipes.
Filtering narrows your data down, but what about reshaping it? In this step, you will use regular expressions to match complex patterns, create brand new fields from existing data, and control exactly which columns appear in your output.
In this step, get ready to:
- Use regex to match patterns and extract new fields from log data.
- Create computed fields with the assignment operator.
- Format your query output into a clean table with select().
Use regular expressions for pattern matching
Basic filters match exact values or simple wildcards. Regular expressions let you describe complex text patterns that would be impossible to express with wildcards alone.
- In your cql-practice repository, clear the Query Editor and enter this regex query to find events containing the word "error" regardless of capitalization:
// Case-insensitive regex search across all fields
/error/i
- Click **Run** to execute the query.
What does this query do?
The forward slashes /error/i tell CQL you are writing a regular expression instead of a plain text filter.
The i flag after the closing slash makes the match case-insensitive, so it catches "Error", "ERROR", and "error" equally. Without the i flag, LogScale is case-sensitive by default.
You should see events that contain variations of the word "error" highlighted in the Event list.
Now try a field-specific regex. This narrows the pattern match to a single field instead of searching all fields.
- Clear the Query Editor and enter this query to match URL paths containing a file extension:
// Match URLs ending with a file extension (e.g., .html, .css, .js)
url = /\w+\.\w+$/
- Click **Run**.
What is happening here?
The syntax url = /pattern/ applies the regex only to the url field. This is faster than a bare regex because CQL does not need to scan every field in every event.
The pattern \w+\.\w+$ matches one or more word characters, a literal dot, then one or more word characters at the end of the string. CQL regex assumes a leading and trailing wildcard, so partial matches within the field value will also appear.
Your results now show only events where the url field contains something resembling a filename with an extension.
Next, use a named capture group to extract part of the match into a brand new field.
- Clear the Query Editor and enter this query to extract filenames from the url field into a new field called fileName:
// Extract the filename portion of the URL into a new 'fileName' field
url = /(?<fileName>\w+\.\w+$)/i
- Click **Run**.
What does the named capture group do?
The syntax (?<fileName>...) is a named capture group. Whatever text matches the pattern inside the parentheses gets extracted into a new event field named fileName.
This is one of the most powerful features in CQL. You can pull structured data out of unstructured text without changing the original event. The new field is available for the rest of your query pipeline.
Click on any event in the results. You should see a new fileName field containing the extracted filename (e.g., index.html or style.css).
Not seeing the fileName field?
Expand an individual event in the Event list by clicking on it. The extracted field appears alongside the original fields. If no events appear at all, check that your sample data actually has a url field containing paths with file extensions.
Help me debug why my named capture group isn't creating a new field.
Create new fields with the assignment operator
Named capture groups extract data from text patterns. The assignment operator lets you create entirely new fields from computations, function results, or combinations of existing fields.
- Clear the Query Editor and enter this query to create a human-readable timestamp field:
// Convert the raw timestamp into a human-readable format
HumanTime := formatTime("%Y-%m-%d %H:%M:%S", field=timestamp, locale=en_US, timezone=Z)
- Click **Run**.
What does the assignment operator do?
The := operator creates a new field and assigns it a value. Here, HumanTime becomes a new field on every event that passes through this query.
The formatTime() function converts a raw epoch timestamp into a formatted string. The format string %Y-%m-%d %H:%M:%S produces output like 2026-06-07 14:30:00. LogScale expects 13-digit epoch timestamps (milliseconds). If your timestamps have 10 digits, multiply by 1000 first.
Expand any event in the results. You should see the new HumanTime field showing a readable date and time.
Now try using the assignment operator with a simple computation.
- Clear the Query Editor and enter this query to create a computed field:
// Create a computed field from an existing numeric value
myField := existingField * 1000
When would I use computed fields?
Computed fields are useful for unit conversions (bytes to kilobytes, seconds to milliseconds), combining fields, or calculating derived metrics. The := operator works with any expression: arithmetic, string functions, or other CQL functions.
Check your field names
Replace existingField with an actual numeric field from your sample data (like statuscode or bytes) to see results. You can click on any event in your data to discover available field names.
Seeing an error with formatTime?
If formatTime() returns empty values, your sample data may not have a field literally named timestamp. Try @timestamp instead, which is LogScale's built-in ingest timestamp. The @ prefix indicates a system-generated field.
Help me figure out which timestamp field to use in my formatTime query.
Format output with the select function
By default, CQL shows all fields on every event. When you are investigating a specific scenario, you only need a few relevant columns. The select() function lets you pick exactly which fields to display in a clean table.
- Clear the Query Editor and enter this query to display only the timestamp, user, status code, and URL fields:
// Filter for client errors, then display only relevant fields
statusCode >= 400
| select([@timestamp, user, statuscode, url])
- Click **Run**.
What does select() do?
The select() function outputs your chosen fields as a table. It is more efficient than table() and is the preferred way to format tabular output in CQL.
The @timestamp field (with the @ prefix) is a system field that LogScale adds automatically during ingestion. Regular fields like user, statuscode, and url come from your log data.
Your results should now appear as a clean table with exactly four columns instead of the full event details.
Now combine everything you have learned in this step into a single multi-stage query.
- Clear the Query Editor and enter this combined query that filters, extracts, transforms, and formats all in one pipeline:
// Complete pipeline: filter, extract, transform, and format
statusCode >= 400
| url = /(?<fileName>\w+\.\w+$)/i
| HumanTime := formatTime("%Y-%m-%d %H:%M:%S", field=@timestamp, locale=en_US, timezone=Z)
| select([HumanTime, fileName, statuscode, url])
- Click **Run**.
What is happening in this pipeline?
This query chains four operations together with pipes. First it filters for error status codes. Then it extracts a filename from the URL. Next it creates a readable timestamp. Finally it presents only the relevant columns.
This is the power of CQL's pipe-based architecture. Each step receives the output of the previous step, and you can build arbitrarily complex analysis workflows by adding more stages.
You should see a table with four columns: HumanTime, fileName, statuscode, and url. The data is filtered to error events only, with a new extracted field and a readable timestamp.
Seeing fewer results than expected?
Each pipe stage reduces the event set further. If url = /(?<fileName>\w+\.\w+$)/i eliminates too many events, it means those events do not have a filename pattern in their URL. This is normal. The query is working correctly by showing only events that match all conditions.
Help me understand why my multi-stage CQL query returns no results.
You now have three powerful tools in your CQL toolkit: regex for flexible pattern matching, the assignment operator for creating computed fields, and select for clean output formatting. Next up, you will learn to aggregate and summarize large datasets with groupBy and count.
Aggregate Data with groupBy and Count
So far, your queries have been filtering individual events. You can find specific log entries, extract fields, and format output.
In real security operations, you rarely care about one event. You need to answer questions like "how many 404 errors happened today?" or "which users generated the most traffic?" This is where aggregation comes in. In this step, you will use CQL's groupBy() and count() functions to summarize thousands of events into meaningful totals.
In this step, get ready to:
- Count events and group results with count() and groupBy().
- Combine multiple aggregation functions inside a single groupBy().
- Sort and filter aggregated results with sort() and test().
Use count() and groupBy() for aggregation
The simplest aggregation in CQL is counting how many events match your filter. The count() function tallies events, and groupBy() splits those counts by field value.
- In your cql-practice repository, clear the query bar and enter this query to count total events by status code:
| groupBy(statuscode)
What does this query do?
The pipe at the start means "take all events in the current time range and pass them into the next function." The groupBy(statuscode) function groups events by their statuscode field value and automatically counts how many events fall into each group.
When you call groupBy() without an explicit function, CQL defaults to counting events per group. The output column is named _count.
- Click Search (or press Enter) to run the query.
- You should see a table with one row per unique status code and a _count column showing how many events have that code.
Now try being explicit about the count function and rename the output column using as=.
- Replace your query with this version that names the output field total:
| groupBy(statuscode, function=count(statuscode, as=total))
What changed?
Adding function=count(statuscode, as=total) explicitly tells CQL which field to count and what to name the result column. The as=total parameter renames the output from the default _count to total. Naming your output columns is essential when you chain multiple functions together later.
- Run the query. You should see the same grouping, but the count column is now labeled total instead of _count.
Seeing zero results?
Make sure your time range selector (top right of the query interface) covers the period when your sample data was ingested. If your data is older than the selected window, no events will match.
Also confirm that your sample data has a statuscode field. Check the Fields panel on the left to see available field names. Your data may use a slightly different name like status or response_code. Adjust the query to match your actual field name.
Help me fix my groupBy query that returns no results.
Apply multiple aggregation functions within groupBy
A single count per group is useful, but real analysis often needs multiple summaries at once. CQL lets you pass an array of functions inside groupBy() using square brackets.
- Enter this query to group by HTTP method and apply two aggregation functions at once:
| groupBy(method, function=([count(statuscode, as=total), collect(url)]))
What does this query do?
The square brackets [...] let you pass multiple functions to a single groupBy(). Here, count(statuscode, as=total) counts events per group, and collect(url) gathers sample values of the url field into a list. This gives you both the scale (how many) and the content (which URLs) in one result set.
- Run the query. You should see one row per HTTP method (GET, POST, etc.) with a total count and a url column containing collected URL values.
You can also group by multiple fields at once. This creates a breakdown across two dimensions.
- Enter this query to group results by both method and status code:
| groupBy([method, statuscode], function=count(statuscode, as=total))
Why group by multiple fields?
Using square brackets around the field list [method, statuscode] creates one row for each unique combination. You will see entries like GET/200, GET/404, POST/500, and so on. This is how analysts build detailed breakdowns of traffic patterns.
- Run the query. You should see rows for each unique method-plus-statuscode combination with a count for each.
Next, try the avg() function and the distinct=true parameter to count unique values.
- Enter this query to count distinct users per status code and calculate an average response time:
| groupBy(statuscode, function=([count(userid, distinct=true, as=uniqueUsers), avg(responsetime, as=avgResponse)]))
What are distinct and avg doing?
The distinct=true parameter tells count() to only count unique values of userid rather than every event. This answers "how many different users hit each status code" instead of "how many total requests."
The avg(responsetime, as=avgResponse) function calculates the average value of the responsetime field within each group. Together, these give you a richer picture of user impact per error type.
- Run the query. Your results should show each status code with a unique user count and an average response time.
Adapt field names to your data
If your sample data does not have fields named userid or responsetime, check the Fields panel on the left and substitute with fields that exist in your dataset. The syntax pattern stays the same regardless of field names.
Getting a function error?
Double-check that your square brackets are balanced and that each function inside them is separated by a comma. A common mistake is forgetting the outer parentheses around the bracket array: function=([...]) needs both ( and ) wrapping the brackets.
Help me debug my multi-function groupBy query.
Sort and filter aggregated results
Once you have grouped and counted events, you often need to order the results or filter out noise. CQL provides sort() for ordering and test() for filtering aggregated output.
- Enter this query to sort status code counts from highest to lowest:
| groupBy(statuscode, function=count(statuscode, as=total))
| sort(total, order=desc)
What does sort do here?
The sort(total, order=desc) function takes the aggregated output and orders rows by the total column in descending order. The most common status codes appear first. You can also use order=asc for ascending (smallest first).
- Run the query. Your results should be ordered with the highest-count status code at the top.
Now use test() to filter aggregated results down to only the groups that matter. This is similar to HAVING in SQL or where in Splunk.
- Enter this query to find users with more than 25 login events:
| groupBy(userid, function=count(userid, as=totalLogons))
| test(totalLogons > 25)
What does test() do?
The test() function evaluates an expression against each row of the aggregated output. Only rows where totalLogons > 25 evaluates to true are kept. Everything else is filtered out.
This is essential for threat hunting. When you have thousands of users, you want to surface only the outliers. High login counts can indicate brute-force attempts or compromised accounts.
- Run the query. Only users whose total login count exceeds 25 should appear in the results.
You can combine sorting and filtering in a single pipeline to get a ranked, filtered view.
- Enter this combined query to find high-activity users sorted by login count:
| groupBy(userid, function=count(userid, as=totalLogons))
| test(totalLogons > 25)
| sort(totalLogons, order=desc)
- Run the query. The results show only high-activity users, ranked from most logins to fewest.
test() returning zero results?
If no rows pass the filter, your dataset may not have any users with more than 25 events. Try lowering the threshold (e.g., test(totalLogons > 5)) to verify the query works. You can always adjust the number back up once you confirm the syntax is correct.
Also ensure the field name inside test() matches the as= name you defined in your count() function exactly. If you named it totalLogons in count, you must use totalLogons in test (case-sensitive).
Help me troubleshoot my test() filter returning no results.
You now have a powerful toolkit for summarizing log data. Next up, you will use conditional logic with case and match statements to transform values and save your queries as reusable functions.
Apply Conditional Logic and Save Queries
Your aggregation queries from the last step gave you powerful summaries of log data. Now you need a way to classify and label events based on their values.
In this step, you will use CQL's conditional logic to transform raw field values into meaningful categories. You will also save your best query as a reusable function that you can invoke in any future query.
In this step, get ready to:
- Write case statements that conditionally assign field values.
- Use match statements for efficient known-field transforms.
- Save a query as a reusable function invocable with the $QueryName() syntax.
Write case statements for value substitution
CQL does not provide a traditional if/else syntax. Instead, it uses case blocks to evaluate conditions and assign values.
Each condition inside a case block is separated by a semicolon. A * at the end acts as a catch-all for events that match none of the earlier conditions.
- Enter the following query in the Query Editor to create a non-destructive case statement that adds a new field without overwriting the original:
statuscode = *
| case {
statuscode < 300 | statusCategory := "Success" ;
statuscode < 400 | statusCategory := "Redirect" ;
statuscode < 500 | statusCategory := "Client Error" ;
* | statusCategory := "Server Error" ;
}
| select([@timestamp, statuscode, statusCategory])
What does this query do?
The statuscode = * filter ensures only events with a statuscode field are evaluated.
Each line inside case {} checks a condition (left of the pipe) and assigns a value (right of the pipe). The := operator creates a new field called statusCategory without changing the original statuscode field. This is a non-destructive case statement.
- Click Run to execute the query.
- You should see a table with three columns: @timestamp, statuscode, and statusCategory. Each row shows the original numeric status code alongside its new human-readable category.
Now try a destructive case statement that overwrites the original field value.
- Enter this query in the Query Editor to overwrite the statuscode field directly:
statuscode = *
| case {
statuscode = 200 | statuscode := "OK" ;
statuscode = 404 | statuscode := "Not Found" ;
statuscode = 500 | statuscode := "Internal Server Error" ;
* ;
}
| select([@timestamp, statuscode])
What is a destructive case statement?
When you assign back to the same field name (statuscode := "OK"), you overwrite the original value. The raw number is replaced by the string in your output.
The bare * catch-all at the end has no assignment, so unmatched events keep their original statuscode value intact.
- Click Run.
- You should see the statuscode column now showing text values like OK and Not Found for matched codes, and the original numbers for unmatched codes.
Query returning no results?
- Make sure each case is separated by a semicolon (;). Missing semicolons between cases will cause a syntax error.
- Check that you have a * catch-all before the closing brace. Without it, events matching no condition may be dropped from results.
- Verify your time range covers events that have a statuscode field.
Help me debug my CQL case statement
Use match statements for known-field transforms
When you know a field is guaranteed to exist on every event you are querying, the match statement offers a cleaner and more performant alternative to case.
The match syntax uses the => arrow operator to map known values to substitutions.
- Enter the following query to transform HTTP method names into descriptive labels:
method = *
| method match {
"GET" => requestType := "Read" ;
"POST" => requestType := "Write" ;
"PUT" => requestType := "Update" ;
"DELETE" => requestType := "Destructive" ;
}
| select([@timestamp, method, requestType])
When should I use match vs case?
Use match when the field you are branching on is guaranteed to exist on every event in your filtered set. It is optimized for direct value lookups.
Use case when you need to evaluate complex conditions (ranges, multiple fields, existence checks) or when the target field might not exist on every event.
- Click Run.
- You should see a table with @timestamp, method, and a new requestType column showing the mapped labels.
Seeing empty requestType values?
- LogScale is case-sensitive. If your data has lowercase method values (get, post), the uppercase match values will not trigger. Adjust the strings to match your actual data.
- Events with method values not covered by any match arm (like PATCH or OPTIONS) will not have a requestType assigned. Add more arms or a default to handle these.
Help me fix my CQL match statement
Save a query as a reusable function
LogScale lets you save any query and invoke it later by name. This turns frequently-used transforms into reusable building blocks that you can call from other queries.
- Return to the Query Editor and enter the non-destructive case statement from earlier:
statuscode = *
| case {
statuscode < 300 | statusCategory := "Success" ;
statuscode < 400 | statusCategory := "Redirect" ;
statuscode < 500 | statusCategory := "Client Error" ;
* | statusCategory := "Server Error" ;
}
- Click Run to execute the query and confirm it returns results.
- Click Save (located above the results panel).
- Select Saved search from the dropdown options.
- In the Name field, enter categorizeStatus.
- Click Save to confirm.
Why name it without spaces?
If a saved query name contains no spaces or special characters, you can invoke it with the simpler $categorizeStatus() syntax. Names with spaces require quotes: $"My Saved Query"().
Now test invoking your saved query as a function inside a new query.
- Clear the Query Editor and enter this query to invoke your saved function:
$categorizeStatus()
| groupBy(statusCategory, function=count())
What does this query do?
The $categorizeStatus() call runs your saved case-statement query as the first stage of the pipeline. It filters to events with a statuscode and creates the statusCategory field.
The pipe then passes those enriched events into groupBy() to count how many events fall into each category. You now have a reusable transform you can plug into any future query.
- Click Run.
- You should see a table with statusCategory (Success, Redirect, Client Error, Server Error) and a _count column showing the totals for each group.
Getting an unknown function error?
- Double-check that you named the saved query exactly categorizeStatus with no spaces or typos. The $ invocation is case-sensitive.
- Confirm the query was saved to the same repository (cql-practice) where you are running the new query. Saved queries are scoped to their repository.
Help me invoke my saved query function in LogScale
You now have conditional logic and reusable query functions in your toolkit. In the next step, you will turn your query results into visual charts and build a dashboard.
Visualize and Present Results
Your CQL queries now filter, transform, aggregate, and conditionally evaluate log data. The final piece is turning those tabular results into something a security team can glance at and immediately understand.
In this step, you will create chart visualizations from your aggregated queries and assemble them into a custom dashboard. Dashboards combine multiple visual widgets into a single operational view of your environment.
In this step, get ready to:
- Create widget visualizations (pie chart, bar chart, time chart, sankey diagram) from groupBy query results.
- Build a custom dashboard combining multiple query widgets for an operational overview.
Create widget visualizations from aggregated data
Aggregation queries produce structured tabular output, but tables are hard to scan quickly. LogScale's Widget Selector lets you transform any aggregated result into a chart with a single click.
- Switch back to the Search tab in your cql-practice repository.
- Enter the following groupBy query in the search bar to count events by status code:
statuscode = *
| groupBy(statuscode, function=count())
- Click Run to execute the query.
- Locate the Widget Selector dropdown above the results panel. It defaults to Auto (Table).
- Click the Widget Selector dropdown and select Pie Chart.
You should see a circular chart showing the proportional distribution of status codes across your log data.
- Click the Widget Selector again and select Bar Chart.
The same data now displays as rectangular bars, making it easier to compare quantities across categories.
What is the Widget Selector?
The Widget Selector is a dropdown that transforms how your query results are displayed. It will grey out any widget types that are incompatible with your current data shape.
For example, a Pie Chart needs a single grouped field with counts. A Sankey Diagram needs two grouped fields to show flow between categories.
- Now try a query that groups by two fields. Enter this in the search bar:
statuscode = *
| groupBy([method, statuscode], function=count())
- Click Run.
- Open the Widget Selector and select Sankey Diagram.
You should see a two-level flow diagram showing how HTTP methods connect to status codes.
- Open the Widget Selector one more time and select Time Chart. If this option is greyed out, it means your current query shape is not compatible with time series display.
Why are some widgets greyed out?
LogScale only enables widget types that match the structure of your query results. A Time Chart displays time series data on a timeline, so it needs time-bucketed results. A single groupBy(statuscode) without a time component will not be compatible.
If you want to enable the Time Chart, try grouping by a time bucket or running a query that produces time-series output.
Not seeing any chart options?
Make sure your query includes an aggregation function like groupBy() or count(). Raw event queries without aggregation only support the Event List widget.
Also check that your Time Selector at the top covers a range that contains data. If there are no results, no visualization can be generated.
help me troubleshoot why my LogScale Widget Selector is greying out all chart options
Build a dashboard with your saved queries
Individual visualizations are useful, but a dashboard combines multiple widgets into a single view. Security operations teams use dashboards to monitor environments at a glance without running queries manually.
- Return to the Search tab and run one of your aggregation queries (for example, the groupBy(statuscode, function=count()) query from earlier).
- Select a chart type from the Widget Selector dropdown (for example, Pie Chart).
- Click Run to render the visualization.
- Click the Save menu at the top right of the results pane.
- Select Dashboard widget.
A Dashboard widget dialog appears with several fields to fill in.
- In the Dashboard field, select New Dashboard.
- Enter CQL Operations Overview in the Dashboard name field.
- Enter a title for the widget in the Widget title field (for example, Status Code Distribution).
- Check the Open dashboard after save checkbox.
- Click Save.
Your new dashboard opens with the first widget displayed.
What is a dashboard widget?
A widget is a saved query paired with a visualization type. The query runs automatically when the dashboard loads, so the chart always shows fresh data within its configured time interval.
Each widget preserves the time interval you had active when you saved it. You can adjust this later from the dashboard's time controls.
- Navigate back to the Search tab to add a second widget.
- Run a different aggregation query. For example, try your multi-field groupBy:
statuscode = *
| groupBy([method, statuscode], function=count())
- Select Bar Chart from the Widget Selector.
- Click Run.
- Click Save and select Dashboard widget.
- In the Dashboard field, select your existing CQL Operations Overview dashboard.
- Give this widget a title (for example, Methods by Status Code).
- Check Open dashboard after save and click Save.
Your dashboard now shows two widgets side by side.
- Repeat this process for at least one more query from your earlier steps (for example, a conditional logic query or a sorted aggregation). Each time, select the existing CQL Operations Overview dashboard to add the widget to.
Resize and rearrange your widgets
Click the Edit icon on the dashboard to enter editing mode. You can drag widgets to rearrange them or resize them by dragging the edges. Click Save when you are happy with the layout.
Dashboard widget option not appearing in the Save menu?
The Dashboard widget option only appears when your query results are visualized as a chart widget. Make sure you have selected a chart type from the Widget Selector and clicked Run before clicking Save.
help me figure out why the Dashboard widget save option is not showing in LogScale
Secret mission
Build a Threat-Hunting Query with Geolocation Visualization
You have the CQL fundamentals down. Now put them to work on a real threat-hunting scenario. Build a query that detects login attempts from external IP addresses, maps them on a world map, and links each result to a threat intelligence service for instant investigation.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project uses Falcon LogScale Community Edition, which is completely free with no credit card required. There are no ongoing costs, and ingested data is automatically deleted after 7 days of retention.
Resources you used:
- Falcon LogScale Community Edition account (free, at cloud.community.humio.com).
- The cql-practice repository containing sample data, saved queries, a dashboard, and visualizations.
- Saved Query functions invocable via $QueryName() syntax.
- Threat-hunting query with world map visualization on your dashboard.
Keep everything running
No action needed. Choose this if you plan to keep practicing CQL or want to reference your saved queries later.
- Your account and repository remain active at no cost.
- Sample data older than 7 days will be automatically removed by LogScale's retention policy. You can re-ingest fresh sample data any time.
- Your saved queries, dashboard, and visualizations persist indefinitely within the repository.
Pause - I'll come back to this later
Since the Community Edition is free with no running costs, pausing simply means stepping away. Your account stays active.
- Log out of Falcon LogScale by clicking your profile icon and selecting the sign-out option.
- Your saved queries and dashboard will be waiting when you return.
- Note that sample log data will expire after 7 days of retention. You will need to re-ingest sample data if you return after that window.
Delete - I don't want to use this again
Remove your project repository and all its contents. This permanently deletes your saved queries, dashboard, and visualizations.
- Open your cql-practice repository in Falcon LogScale.
- Navigate to the repository's Settings page.
- Scroll to the bottom and use the delete option to permanently remove the repository and all its data, queries, and dashboards.
- Confirm the deletion when prompted.
What about my account?
The Falcon LogScale Community Edition account itself is free and costs nothing to maintain. Deleting the repository removes all project resources. You can keep the account for future CQL practice without any financial impact.
Nice Work!
Nice Work!
Nice work! You just leveled up your security operations toolkit by building a complete CQL query workbook in Falcon LogScale Community Edition.
You've learned how to:
- Write progressively complex CQL queries that filter, extract, aggregate, and transform security log data using a pipe-based query architecture.
- Build reusable saved queries and conditional logic with case and match statements for real-world security operations workflows.
- Create visualizations and dashboards from aggregated query results to present security data in actionable chart formats.
- Secret Mission: Build a geolocation-based threat-hunting query that maps anomalous login patterns on a world map with threat intelligence hyperlinks.
Ready to quiz yourself?