Expressions
The ExprStr grammar for dynamic values in scenarios and jobs.
Tracemill uses a lightweight expression language called ExprStr for dynamic value generation in scenario state, job state, workload bindings, emit fields, and event-type defaults.
Syntax
| Syntax | Meaning | Example |
|---|---|---|
literal | String, number, bool, null, or nested object/array | "us-east-1", 443, true |
gen.<type>() | Built-in generator | gen.uuid(), gen.ipv4() |
gen.<type>(k=v, ...) | Generator with parameters | gen.int(min=0, max=100) |
fn.<name>(input) | Pure transform of a positional input — literal, ref.*, or another expression (nesting allowed) | fn.upper(fn.md5(ref.image)) |
fn.<name>(input, k=v, ...) | Transform with named params | fn.prefix(ref.uuid, n=8) |
ref.<var> | Read a resolved state variable | ref.source_ip |
ref.<var>.<path> | Dot-path into an object variable | ref.user.name |
pool.<id> | Draw a full record from a pool | pool.iam-users |
pool.<id>.<field> | Draw a record, extract a field | pool.iam-users.email |
pool.<id>[] | Whole pool: all entries in order as a list | pool.iam-users[] |
Quoting parameter values
Named parameter values may be wrapped in double quotes to include characters
that would otherwise be parsed as syntax — most commonly a comma, which
normally separates parameters. Inside quotes, commas and brackets are literal;
use \" for a literal double quote and \\ for a literal backslash (other
backslash sequences pass through untouched, so Windows paths and Go time
layouts need not be doubled). Quoting is opt-in: unquoted values keep their
existing behaviour, so regex patterns and other bracketed values are unchanged.
# A custom timestamp layout containing commas must be quoted, otherwise the
# commas split the parameter list and parsing fails:
scan_completed_at: gen.timestamp(format="Jan 2, 2006, 3:04:05 PM", offset=-8h, jitter=1h)Resolution Rules
- Scenario
stateis resolved once per scenario run in topological order - Emit
fieldsare resolved per emit step against the (immutable) scenario state — fields cannot reference each other, onlyref.*into state - Event-type
defaultsare resolved per emit in topological order, before being merged under the step's emit fields (step fields win on conflict) - Job
stateis resolved once at job start and is available to workload bindings viaref.* - Workload
bindingsare re-evaluated on every iteration of aloop:workload (and on every combo of amatrix:workload), sogen.*andpool.*expressions in bindings produce fresh values each iteration - Circular references in scenario
stateare detected at load/compile time; cycles in jobstate, event-typedefaults, and workloadbindingsare detected when those scopes are evaluated ref.<var>resolution depends on the call site:- Scenario
state→ other scenario state vars. Validated at load time. - Job
state→ other job state vars. Validated at evaluation time (per run). - Event-type
defaults→ other defaults entries (scenario state is not in scope). Validated per emit. - Emit
fields→ the scenario's resolved state. Validated per emit. - Workload
bindings→ ambient job state. Validated per binding evaluation (per iteration / per matrix combo). - Nested
ref.*leaves inside an object/arraystate:value (scenario or job) participate in the same dependency graph as top-levelref.*in the same scope, so per-scope validation timing applies unchanged.
- Scenario
pool.*expressions are only valid in job state and workload bindings, not in scenario state or fieldspool.<id>[]resolves to every pool entry in order as a list (records forcsv, scalars forstring_list/ip_range), bypassing the pool's sampling mode.[]is only valid as the exact trailing suffix on a bare pool ID —pool.<id>.<field>[]orpool.<id>[].fieldare rejected. The list is capped at 100,000 entries.- Nested state values evaluate. String leaves nested inside an object or array
state:value (scenario or job) parse as ExprStr and resolve as part of the owning state variable —event_name: gen.uuid()inside a record draws a UUID, and${ref.region}inside a list element interpolates against the rest of state. Resolution timing matches top-level state: once per run for scenarios, once at job start for jobs (distinct from emitfields:, which evaluate per emit). The state-variable dependency graph spans nested leaves too, so cycles and undefined refs surface as load-time (scenario) or evaluation-time (job) errors with the offending leaf path. Event-typedefaults:are the exception: nested values there stay literal — only top-level default leaves are ExprStr.
Generators
Generators produce realistic random values on each run. Parameters are optional unless noted; (d) marks the default.
Identity
| Generator | Description | Example output |
|---|---|---|
gen.uuid() | Random UUID v4 | f47ac10b-58cc-4372-a567-0e02b2c3d479 |
Network
| Generator | Description | Example output |
|---|---|---|
gen.ipv4() | Random IPv4 address | 192.168.1.42 |
gen.ipv6() | Random IPv6 address | 2001:db8::1 |
gen.mac() | Random MAC address | 00:1a:2b:3c:4d:5e |
gen.port() | Random port (1024–65535) | 8443 |
gen.domain() | Random domain name | example.org |
gen.url() | Random URL | https://example.com/path |
gen.hostname() | Random hostname (see Hostname classes) | DESKTOP-A1B2C3D |
Time
| Generator | Description |
|---|---|
gen.timestamp() | Current time in RFC3339 (default) |
gen.timestamp(format=...) | Named format: iso8601, RFC3339Nano, unix, millis, micros, nanos, epoch_float, RFC1123, ANSIC, wineventlog, sysmon, syslog, apache, kitchen, or a custom Go layout string |
gen.timestamp(offset=...) | Shift from current time: positive (2h30m) is in the future, negative (-6h) is in the past |
gen.timestamp(truncate=...) | Truncate to a time boundary before applying offset: 1h snaps to the start of the current hour, 24h snaps to midnight UTC (truncation is relative to the UTC zero time) |
gen.timestamp(jitter=...) | Add a symmetric random spread after offset: 30m varies the result by up to 30 minutes in either direction |
All params are optional and combinable. Operation order: truncate → offset → jitter → format. A custom Go layout that contains commas (e.g. Jan 2, 2006, 3:04:05 PM) must be double-quoted — see Quoting parameter values.
People / Identity
| Generator | Description |
|---|---|
gen.username() | Random username |
gen.email() | Random email address |
gen.user_agent() | Random HTTP User-Agent string |
gen.country() | Random country name |
gen.timezone() | Random IANA timezone |
Numerics
| Generator | Description |
|---|---|
gen.int() | Random integer 0–1,000,000 |
gen.int(min=N, max=N) | Random integer in custom range |
gen.float() | Random float 0.0–1.0 |
gen.float(min=N, max=N) | Random float in custom range |
Hex
| Generator | Description |
|---|---|
gen.hex(len=N) | Random lowercase hex string of length N (1–4096) |
gen.hex(len=N, case=upper) | Random uppercase hex string |
Use gen.hex for fields that need a random hex value uncorrelated with any other state — Windows Activity GUIDs, request correlation IDs, opaque ETags. For hash fields that should be consistent with other state (e.g. the same file's hash across multiple events), use the fn.* hash functions over a state variable instead.
Patterns
| Generator | Description |
|---|---|
gen.regex(pattern=...) | Random string matching an RE2 pattern |
Use gen.regex for arbitrary string formats that the other generators don't cover — provider resource identifiers, opaque tokens, structured IDs.
state:
guardrail_id: gen.regex(pattern=[a-z0-9]+) # AWS Bedrock guardrailIdentifier
vpc_id: gen.regex(pattern=vpc-[0-9a-f]{8}) # structured resource ID
env_host: gen.regex(pattern=(prod|staging|dev)-[a-z]{4})Length is expressed through the pattern's own quantifiers:
- Bounded quantifiers are honored exactly:
{8}→ 8 repetitions,{8,12}→ 8–12. - Unbounded quantifiers fall back to defaults:
+repeats 1–16,*repeats 0–16,{n,}repeats n–(n+16). - Anchors (
^,$,\b) are no-ops — output is generated, not matched. .yields visible ASCII (printable, excluding space) so values don't carry stray whitespace. Character classes prefer printable ASCII, but a class with no printable-ASCII members (e.g.\p{Han}) draws from its own runes so the value still matches.
HTTP
| Generator | Description |
|---|---|
gen.http_method() | Random HTTP method |
gen.http_status() | Random HTTP status code |
gen.http_status(class=2xx|4xx|5xx) | Status code from a specific class |
AWS / Cloud
| Generator | Description |
|---|---|
gen.aws_region() | Random AWS region (e.g. us-east-1) |
gen.aws_account_id() | Random 12-digit AWS account ID |
gen.aws_arn(class=...) | Random AWS ARN. Classes: user(d), role, assumed-role, lambda, instance, bucket, log-group, ecs-task. Optional name=<str> to pin the resource name. |
gen.aws_identity(type=...) | CloudTrail userIdentity map. Types: IAMUser(d), AssumedRole, AWSService, FederatedUser, Root, AWSAccount. Optional accountId, userName. |
gen.aws_access_key(...) | AWS access key object. Params: userName, accountId, status=Active(d)|Inactive, type=Permanent(d)|Temporary. |
Hostname classes
gen.hostname() generates realistic hostnames for different platform styles via the class parameter.
| Class | Example | Params |
|---|---|---|
windows-workstation (default) | DESKTOP-A1B2C3D | domain — append domain for FQDN (e.g. domain=corp.local → DESKTOP-A1B2C3D.corp.local) |
windows-server | DC01, SQL14 | domain — append domain for FQDN |
ec2-private | ip-10-0-1-42.ec2.internal | region — AWS region (default: us-east-1). Uses ip-...ec2.internal for us-east-1, ip-...REGION.compute.internal for others. |
ec2-resource | i-0a3f7c9e12b456d89.ec2.internal | region — AWS region (default: us-east-1). Uses instance-ID-based naming. |
ec2-public-ipv4 | ec2-54-201-100-1.compute-1.amazonaws.com | region — AWS region (default: us-east-1). Uses compute-1 for us-east-1, REGION.compute for others. |
linux | web-prod-us-east-07 | — |
state:
# Windows workstation FQDN
computer: gen.hostname(class=windows-workstation, domain=corp.local)
# EC2 private DNS in us-west-2
ec2_host: gen.hostname(class=ec2-private, region=us-west-2)
# Linux infrastructure hostname
server: gen.hostname(class=linux)Functions
fn.* expressions are pure, deterministic transforms over a single positional input plus optional named parameters. Use them when shaping field values that need normalisation, slicing, or trimming.
Functions never produce random data — that's gen.*'s job. The positional input can be a literal, a ref.*, or another expression: fn.*, gen.*, a ${ref.*} interpolation, or (in job state) pool.*. Calls compose freely, so transforms can be chained inline:
state:
image: "C:\\Users\\victim\\Downloads\\payload.exe"
image_md5: fn.upper(fn.md5(ref.image))Intermediate state vars are still a good idea when the same value is reused — they keep emit fields short and make the dependency graph easy to scan:
state:
user: gen.username()
user_lower: fn.lower(ref.user)
user_short: fn.prefix(ref.user_lower, n=4)pool.* remains scoped to job state and workload bindings; wrapping it in fn.* does not smuggle it into scenario state or emit fields.
String functions (Tier 1)
| Expression | Description |
|---|---|
fn.lower(s) | Lowercase the input (Unicode-aware) |
fn.upper(s) | Uppercase the input (Unicode-aware) |
fn.trim(s) | Strip leading/trailing Unicode whitespace |
fn.prefix(s, n=N) | First N runes of s. Clamps to the input's rune length. |
fn.suffix(s, n=N) | Last N runes of s. Clamps to the input's rune length. |
Hash functions
| Expression | Description |
|---|---|
fn.md5(s) | MD5 hash of s as lowercase hex (32 chars) |
fn.sha1(s) | SHA-1 hash of s as lowercase hex (40 chars) |
fn.sha256(s) | SHA-256 hash of s as lowercase hex (64 chars) |
fn.sha512(s) | SHA-512 hash of s as lowercase hex (128 chars) |
Hash functions are deterministic: the same input always produces the same digest. That's exactly what you want for events that should agree on a file's hash across emits — compute the hash once in scenario state: and reference the same state variable from every emit:
state:
file_path: "C:\\Users\\victim\\Downloads\\invoice.exe"
file_md5: fn.md5(ref.file_path)
file_sha256: fn.sha256(ref.file_path)For an unrelated random hex value (e.g. an Activity GUID where no underlying content is being modelled), use gen.hex instead.
Type coercion
Inputs that resolve to scalar values (string, integer, float, bool) are coerced to their string form. Map/array inputs return an error rather than silently rendering as map[k:v]-style strings — this catches mistakes like passing a full gen.aws_identity() result where a single field was meant.
Errors
fn.prefix and fn.suffix require a non-negative integer n. A missing, non-integer, or negative n produces a clear runtime error scoped to the function call site.