Skip to content

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.

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.

operators/ocsf/auth.tql
// Normalize authentication logs to OCSF Authentication event class
class_uid = 3002
category_uid = 3
activity_id = 1 if outcome == "success" else 2
actor.user.name = username
actor.user.uid = user_id
src_endpoint.ip = src_ip
dst_endpoint.ip = dst_ip
drop username, user_id, src_ip, dst_ip, outcome

After installing the package, use the operator in any pipeline:

from_file "auth.json"
acme::ocsf::auth
publish "ocsf-events"

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.

Each parameter supports the following fields:

FieldRequiredDescription
nameYesParameter name, used as $name in the operator body
typeNoType constraint for the parameter value
descriptionNoDocumentation string for the parameter
defaultNoDefault value if the argument is not provided

The type field constrains what values the parameter accepts:

TypeDescription
fieldA field selector (for example, name). Cannot have non-null defaults
stringA string literal or expression
intAn integer value
doubleA floating-point value
boolA boolean value
ipAn IP address
secretA secret string (accepts string literals)

If you omit the type field, the parameter accepts any value.

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.

This section shows common patterns for defining and using operator parameters.

Positional arguments are passed in order when calling the operator. Define them under the args.positional key:

operators/tag.tql
---
args:
positional:
- name: field
type: field
- name: value
type: string
---
$field = $value

Call this operator with positional arguments:

from {x: 1}
acme::tag name, "Alice"
{x: 1, name: "Alice"}

Named arguments use the name=value syntax and can have default values. Define them under the args.named key:

operators/tag.tql
---
args:
positional:
- name: field
type: field
- name: value
type: string
named:
- name: prefix
type: string
default: ""
---
$field = $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"}

Positional arguments with a default value become optional. Callers can omit them, and Tenzir substitutes the default:

operators/greet.tql
---
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!"}

The field type enables dynamic field selection. The caller passes a field path, and the operator uses it to read or write data:

operators/double_value.tql
---
args:
positional:
- name: target
type: field
---
$target = $target * 2

Using the operator:

from {count: 5, score: 10}
acme::double_value count
{count: 10, score: 10}

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:

operators/redact.tql
---
args:
positional:
- name: input
type: field
named:
- name: target
type: field
default: null
description: "Optional field to redact"
---
if $target != null {
$target = "REDACTED"
}
$input = $input

When 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:

operators/tag.tql
---
args:
positional:
- name: field
type: field
named:
- name: suffix
type: string
default: null
---
if $suffix != null {
$field = $field + $suffix
}

Parameterized operators can call other operators, including passing through their own parameters:

operators/transform.tql
---
args:
positional:
- name: field
type: field
named:
- name: multiplier
type: int
default: 2
---
utils::scale $field, factor=$multiplier

Modularize 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:

OperatorPurpose
acme::parseParse raw input into events
acme::cleanNormalize fields, fix types
acme::ocsf::baseFallback: maps to OCSF Base Event
acme::ocsf::mapDispatcher: 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:

operators/ocsf/map.tql
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:

operators/ocsf/base.tql
// Map unrecognized events to OCSF Base Event (class_uid: 0)
class_uid = 0
category_uid = 0
activity_id = 0
raw_data = move raw_message

Each leaf operator handles one specific event type:

operators/ocsf/map/dns.tql
// Map DNS logs to OCSF DNS Activity (class_uid: 4003)
class_uid = 4003
category_uid = 4
activity_id = 1
query.hostname = move query_name
query.type = move query_type
answers = move dns_answers
// ... additional field mappings

These operators compose into an end-to-end pipeline:

from_file "logs/mixed.json"
acme::parse
acme::clean
acme::ocsf::map
publish "ocsf"

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

Last updated: