Concepts

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

SyntaxMeaningExample
literalString, number, bool, null, or nested object/array"us-east-1", 443, true
gen.<type>()Built-in generatorgen.uuid(), gen.ipv4()
gen.<type>(k=v, ...)Generator with parametersgen.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 paramsfn.prefix(ref.uuid, n=8)
ref.<var>Read a resolved state variableref.source_ip
ref.<var>.<path>Dot-path into an object variableref.user.name
pool.<id>Draw a full record from a poolpool.iam-users
pool.<id>.<field>Draw a record, extract a fieldpool.iam-users.email
pool.<id>[]Whole pool: all entries in order as a listpool.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 state is resolved once per scenario run in topological order
  • Emit fields are resolved per emit step against the (immutable) scenario state — fields cannot reference each other, only ref.* into state
  • Event-type defaults are resolved per emit in topological order, before being merged under the step's emit fields (step fields win on conflict)
  • Job state is resolved once at job start and is available to workload bindings via ref.*
  • Workload bindings are re-evaluated on every iteration of a loop: workload (and on every combo of a matrix: workload), so gen.* and pool.* expressions in bindings produce fresh values each iteration
  • Circular references in scenario state are detected at load/compile time; cycles in job state, event-type defaults, and workload bindings are 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/array state: value (scenario or job) participate in the same dependency graph as top-level ref.* in the same scope, so per-scope validation timing applies unchanged.
  • pool.* expressions are only valid in job state and workload bindings, not in scenario state or fields
  • pool.<id>[] resolves to every pool entry in order as a list (records for csv, scalars for string_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>[] or pool.<id>[].field are 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 emit fields:, 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-type defaults: 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

GeneratorDescriptionExample output
gen.uuid()Random UUID v4f47ac10b-58cc-4372-a567-0e02b2c3d479

Network

GeneratorDescriptionExample output
gen.ipv4()Random IPv4 address192.168.1.42
gen.ipv6()Random IPv6 address2001:db8::1
gen.mac()Random MAC address00:1a:2b:3c:4d:5e
gen.port()Random port (1024–65535)8443
gen.domain()Random domain nameexample.org
gen.url()Random URLhttps://example.com/path
gen.hostname()Random hostname (see Hostname classes)DESKTOP-A1B2C3D

Time

GeneratorDescription
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: truncateoffsetjitterformat. 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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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

GeneratorDescription
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.

ClassExampleParams
windows-workstation (default)DESKTOP-A1B2C3Ddomain — append domain for FQDN (e.g. domain=corp.localDESKTOP-A1B2C3D.corp.local)
windows-serverDC01, SQL14domain — append domain for FQDN
ec2-privateip-10-0-1-42.ec2.internalregion — AWS region (default: us-east-1). Uses ip-...ec2.internal for us-east-1, ip-...REGION.compute.internal for others.
ec2-resourcei-0a3f7c9e12b456d89.ec2.internalregion — AWS region (default: us-east-1). Uses instance-ID-based naming.
ec2-public-ipv4ec2-54-201-100-1.compute-1.amazonaws.comregion — AWS region (default: us-east-1). Uses compute-1 for us-east-1, REGION.compute for others.
linuxweb-prod-us-east-07
Usage examples
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)

ExpressionDescription
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

ExpressionDescription
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.