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. It uses TQL type
definition syntax:
| Type | Description |
|---|---|
field | A field selector (for example, name). Cannot have non-null defaults |
string | A string literal or expression |
int | A signed integer value |
uint | An unsigned integer value |
float | A floating-point value |
bool | A boolean value |
duration | A duration value |
time | A timestamp value |
ip | An IP address |
subnet | A subnet value |
blob | A blob value |
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 = f"{$prefix}{$value}"Call 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 = f"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 = f"{$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 event types to OCSF. Keep source-specific cleanup, shared OCSF setup, and dispatch in one main mapper, then delegate only event-specific mapping details to leaf operators:
| Operator | Purpose |
|---|---|
acme::ocsf::map | Main mapper: cleanup, shared OCSF setup, and dispatch |
acme::ocsf::base | Fallback: maps to OCSF Base Event |
acme::ocsf::events::* | Event-specific mapping such as dns, http, or auth |
This hierarchy maps directly to your directory structure:
Directoryoperators/
Directoryocsf/
- base.tql
- map.tql
Directoryevents/
- dns.tql
- http.tql
- auth.tql
The main mapper keeps source residue in a product-specific acme namespace,
performs source-specific cleanup, adds shared OCSF fields, routes events to
specialized mappers based on a stable event discriminator, and returns the
mapped OCSF event:
this = { acme: this }
ocsf.metadata = { product: { name: "ACME Logs", vendor_name: "ACME", }, version: "1.8.0",}ocsf.severity_id = 1
match acme.event_type { "dns" => { acme::ocsf::events::dns } "http" => { acme::ocsf::events::http } "auth" => { acme::ocsf::events::auth } _ => { acme::ocsf::base }}
this = {...ocsf, unmapped: acme}The fallback operator ensures that unrecognized events still conform to OCSF by mapping them to the Base Event class:
@name = "ocsf.base_event"ocsf.category_uid = 0ocsf.class_uid = 0ocsf.activity_id = 0ocsf.type_uid = 0ocsf.severity_id = 0ocsf.time = now()Each leaf operator handles one specific event type:
@name = "ocsf.dns_activity"ocsf.category_uid = 4ocsf.class_uid = 4003ocsf.activity_id = 1ocsf.type_uid = ocsf.class_uid * 100 + ocsf.activity_id
ocsf.query.hostname = move acme.query_nameocsf.query.type = move acme.query_typeocsf.answers = move acme.dns_answers// ... additional field mappingsAfter the package mapper runs, callers can run the shared OCSF helpers. The
mapper should produce minimal OCSF, and ocsf::derive expands it with
derived sibling fields before ocsf::cast validates the final shape:
acme::ocsf::mapocsf::deriveocsf::castThese operators compose into an end-to-end pipeline:
from_file "logs/mixed.json" { read_json}acme::ocsf::mapocsf::deriveocsf::castpublish "ocsf"The parser stays with the source operator, while acme::ocsf::map owns cleanup,
shared OCSF setup, and dispatch.
Design principles
Section titled “Design principles”When building operator hierarchies, follow these guidelines:
- Expose one mapper contract: Accept parsed source events through a main
package mapper, for example
acme::ocsf::map. - Keep cleanup close to mapping: Put source-specific normalization and shared OCSF setup in the main mapper before dispatch.
- Produce minimal OCSF: Set identifiers and source-derived attributes in
the mapper, then use
ocsf::deriveto add derived sibling fields. - Use dispatchers for routing: Route events based on type or other criteria.
- Mirror directory structure: Operator names reflect their location.
- Provide fallbacks: Handle unrecognized inputs gracefully.