This guide shows you how to create user-defined operators (UDOs) for your package. You’ll learn how to define operators with positional and named arguments, and how to test them with the Test Framework.
Create a user-defined operator
Section titled “Create a user-defined operator”User-defined operators (UDOs) are reusable building blocks that you can use
in your pipelines. Place operator files in the operators directory of your
package.
Tenzir names operators using the convention <package>::[dirs...]::<basename>.
For example, a file at operators/ocsf/map.tql in a package with ID acme
becomes the operator acme::ocsf::map.
// Normalize authentication logs to OCSF Authentication event classclass_uid = 3002category_uid = 3activity_id = 1 if outcome == "success" else 2actor.user.name = usernameactor.user.uid = user_idsrc_endpoint.ip = src_ipdst_endpoint.ip = dst_ipdrop username, user_id, src_ip, dst_ip, outcomeAfter installing the package, use the operator in any pipeline:
from_file "auth.json"acme::ocsf::authpublish "ocsf-events"Add parameters to operators
Section titled “Add parameters to operators”Operators can accept positional and named arguments, enabling you to create
flexible, reusable building blocks that match the calling conventions of
built-in operators. Define parameters in a YAML frontmatter block at the
beginning of the .tql file.
Parameter schema
Section titled “Parameter schema”Each parameter supports the following fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Parameter name, used as $name in the operator body |
type | No | Type constraint for the parameter value |
description | No | Documentation string for the parameter |
default | No | Default value if the argument is not provided |
Supported types
Section titled “Supported types”The type field constrains what values the parameter accepts:
| Type | Description |
|---|---|
field | A field selector (for example, name). Cannot have non-null defaults |
string | A string literal or expression |
int | An integer value |
double | A floating-point value |
bool | A boolean value |
ip | An IP address |
secret | A secret string (accepts string literals) |
If you omit the type field, the parameter accepts any value.
Type checking
Section titled “Type checking”Tenzir validates parameter types at compile time when possible:
- Compile-time checking occurs when arguments are constant values
- Runtime checking defers validation for expressions containing runtime data
If a type mismatch occurs, Tenzir reports an error with the expected type and shows usage information for the operator.
Work with operator parameters
Section titled “Work with operator parameters”This section shows common patterns for defining and using operator parameters.
Define positional arguments
Section titled “Define positional arguments”Positional arguments are passed in order when calling the operator. Define them
under the args.positional key:
---args: positional: - name: field type: field - name: value type: string---
$field = $valueCall this operator with positional arguments:
from {x: 1}acme::tag name, "Alice"{x: 1, name: "Alice"}Define named arguments
Section titled “Define named arguments”Named arguments use the name=value syntax and can have default values. Define
them under the args.named key:
---args: positional: - name: field type: field - name: value type: string named: - name: prefix type: string default: ""---
$field = $prefix + $valueCall this operator with both positional and named arguments:
from {x: 1}acme::tag name, "Alice", prefix="User: "{x: 1, name: "User: Alice"}Make arguments optional
Section titled “Make arguments optional”Positional arguments with a default value become optional. Callers can omit
them, and Tenzir substitutes the default:
---args: positional: - name: name type: string default: "World"---
greeting = "Hello, " + $name + "!"Calling the operator without arguments uses the default value:
from {}acme::greet{greeting: "Hello, World!"}Passing an explicit argument overrides the default:
from {}acme::greet "Alice"{greeting: "Hello, Alice!"}Use field parameters
Section titled “Use field parameters”The field type enables dynamic field selection. The caller passes a field
path, and the operator uses it to read or write data:
---args: positional: - name: target type: field---
$target = $target * 2Using the operator:
from {count: 5, score: 10}acme::double_value count{count: 10, score: 10}Detect whether an argument was provided
Section titled “Detect whether an argument was provided”Any typed parameter supports default: null, making it optional without
requiring a concrete fallback value. Inside the operator body, compare the
parameter against null to check whether the caller provided it.
This is especially useful for field parameters, which cannot have non-null
defaults because a field selector is not a constant value:
---args: positional: - name: input type: field named: - name: target type: field default: null description: "Optional field to redact"---
if $target != null { $target = "REDACTED"}$input = $inputWhen the caller omits target, the operator skips the redaction:
from {message: "hello", secret: "s3cret"}acme::redact message{message: "hello", secret: "s3cret"}When the caller provides target, the operator redacts that field:
from {message: "hello", secret: "s3cret"}acme::redact message, target=secret{message: "hello", secret: "REDACTED"}The same pattern works for other types. For example, an optional string
parameter that only applies when provided:
---args: positional: - name: field type: field named: - name: suffix type: string default: null---
if $suffix != null { $field = $field + $suffix}Call other operators
Section titled “Call other operators”Parameterized operators can call other operators, including passing through their own parameters:
---args: positional: - name: field type: field named: - name: multiplier type: int default: 2---
utils::scale $field, factor=$multiplierModularize packages with operator hierarchies
Section titled “Modularize packages with operator hierarchies”Complex packages benefit from breaking functionality into a hierarchy of operators that call each other. This pattern keeps individual operators focused, enables independent testing, and creates a clear structure that mirrors your directory layout.
Consider a package that normalizes various log formats to OCSF. You can organize the operators into layers:
| Operator | Purpose |
|---|---|
acme::parse | Parse raw input into events |
acme::clean | Normalize fields, fix types |
acme::ocsf::base | Fallback: maps to OCSF Base Event |
acme::ocsf::map | Dispatcher: routes to specific mappers |
acme::ocsf::map::* | Event-specific mapping (dns, http, auth, etc.) |
This hierarchy maps directly to your directory structure:
Directoryoperators/
- parse.tql
- clean.tql
Directoryocsf/
- base.tql
- map.tql
Directorymap/
- dns.tql
- http.tql
- auth.tql
The dispatcher operator routes events to specialized mappers, e.g., based on event type:
if @name == "acme.dns" { acme::ocsf::map::dns} else if @name == "acme.http" { acme::ocsf::map::http} else if @name == "acme.auth" { acme::ocsf::map::auth} else { acme::ocsf::base}The fallback operator ensures that unrecognized events still conform to OCSF by mapping them to the Base Event class:
// Map unrecognized events to OCSF Base Event (class_uid: 0)class_uid = 0category_uid = 0activity_id = 0raw_data = move raw_messageEach leaf operator handles one specific event type:
// Map DNS logs to OCSF DNS Activity (class_uid: 4003)class_uid = 4003category_uid = 4activity_id = 1query.hostname = move query_namequery.type = move query_typeanswers = move dns_answers// ... additional field mappingsThese operators compose into an end-to-end pipeline:
from_file "logs/mixed.json"acme::parseacme::cleanacme::ocsf::mappublish "ocsf"Design principles
Section titled “Design principles”When building operator hierarchies, follow these guidelines:
- Keep operators focused: Each operator should do one thing well
- Use dispatchers for routing: Route events based on type or other criteria
- Mirror directory structure: Operator names reflect their location
- Enable independent testing: Each layer can be tested in isolation
- Provide fallbacks: Handle unrecognized inputs gracefully