Concepts

Scenarios

Defining step-by-step event generation sequences.

A scenario is the core building block in Tracemill. It defines a stateful sequence of event emissions — a single "story" of activity, like a brute-force attack or a user login session.

Structure

A scenario YAML file has three main sections:

type: scenario
state:
  source_ip: gen.ipv4()
  user_id: gen.uuid()
steps:
  - emit:
      type: "console-login@1.0"
      fields:
        src_ip: ref.source_ip
        user: ref.user_id
        outcome: "failure"
  - emit:
      type: "console-login@1.0"
      fields:
        src_ip: ref.source_ip
        user: ref.user_id
        outcome: "success"

State

The state block declares variables resolved once per scenario run. Variables can reference generators (gen.*) or literal values. They are resolved in topological order — a variable can reference another variable declared in the same block via ref.*.

Steps

Each step is one of:

  • emit — emit an event of a given type. The fields block maps field names to values — literals, generator calls, or references to state variables.
  • wait — pause for a duration (with optional jitter_pct).
  • foreach — iterate a collection, running a nested step sequence once per element.

Looping with foreach

A foreach step lets a scenario define its own repetition, so a scenario named for a burst of activity actually produces that burst when run standalone — without relying on a job to loop it.

state:
  actions:
    - { event_name: DescribeInstances, event_source: ec2.amazonaws.com }
    - { event_name: ListBuckets,       event_source: s3.amazonaws.com }
steps:
  - foreach:
      in: ref.actions      # required — a ref.<var> pointing at a collection; never an inline list
      as: action           # required — a dot-free name; the per-iteration binding
      steps:               # required (≥1) — recursive: emit | wait | foreach
        - emit:
            event_type: aws.cloudtrail@v1
            fields:
              eventName:   ref.action.event_name
              eventSource: ref.action.event_source
        - wait: { duration: 2s, jitter_pct: 0.3 }
  • in must be a ref.<var>[.<path>] — a reference to a scenario state variable (or an enclosing iteration variable, for nested loops). It is never an inline literal list, so a job can override the collection through the normal binding mechanism. Override it with a job-state inline list (actions: ref.action_list) or by binding a whole pool (actions: pool.discovery-actions[]).
  • as is the per-iteration binding. The whole element is bound under this name for the nested steps only — it does not leak across iterations or back into scenario state. A record element is read via ref.<as>.<field> (same-row fields stay correlated); a scalar element is read directly as ref.<as>.
  • Record fields evaluate. Values nested inside a record in state: parse as ExprStr — event_name: gen.uuid() inside a record draws a fresh UUID at run start, and ${ref.region} interpolates against scenario state. Resolution timing matches top-level state: once per scenario run, not per emit (the latter is how emit fields: work). Event-type defaults: remain the exception: nested values there stay literal.
  • Nesting is allowed (a foreach inside a foreach), capped at 5 levels.
  • Caps: a single collection may have at most 100,000 elements, and a single run may execute at most 1,000,000 total iterations across all (including nested) loops.

Event Types

Event types (e.g., console-login@1.0) are defined separately and provide a schema with default values. Scenario fields override event type defaults. A timestamp field is stamped automatically from the runner's clock.