PROTEUS docs
v0.1.0 Jeremy Hall

CLI walkthroughs

Seven worked journeys through the command line, with the output each command really prints.

Every transcript below is real output from this build, captured on macOS with Python 3.12 in us-west-2 against the estate a fresh proteus plan writes: the northwind-rx preset, 18 services, 8 infra nodes, 3 tenants, no faults, rev 1, fingerprint d7caf912a5a9862f. That is deliberate — it is the estate you will have after the first command on this page, so every number here is one you can produce rather than one you have to take on trust.

Where a command would have spent money, it was run with --dry-run or not run at all, and the page says which.

scripts/check-docs-cli.py re-derives the figures below from live runs and fails if any of them has drifted. Run it after changing anything that prints.

Transcripts were captured at a terminal width of 100. Rich wraps its tables to the terminal, so your line breaks will differ; at narrower widths it truncates long cells rather than wrapping them, which is why inventory-consumer appears in full here and as inventory-consum… in an 80-column terminal.

Commands are shown as proteus .... If you have not put the virtualenv on your path, prefix with .venv/bin/.

Note Record counts and dollar figures move slightly between runs. The projection window is anchored to now and the modeled traffic curve is diurnal, so the same command a minute later counts different metric points and RUM events. Spans and metric-series counts are stable. Where a figure wobbles, this page gives the measured spread instead of pretending one run was the answer.

doctor: read it before you need it#

doctor is read-only. It never changes an AWS resource and never sends telemetry. Run it first, run it again after bootstrap, and run it when anything looks wrong.

proteus doctor
 check         ┃      ┃ detail
━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 region        │ OK   │ us-west-2 supports CloudWatch Omni
 credentials   │ FAIL │ could not resolve credentials: TokenRetrievalError: Error when
               │      │ retrieving token from sso: Token has expired and refresh failed
 world model   │ OK   │ northwind-rx rev 1: 18 services, 8 infra, 3 tenants, 0
               │      │ fault(s), fingerprint d7caf912a5a9862f
 template size │ WARN │ template 37,797 bytes across 47 resources, sent inline (74% of the
               │      │ 51,200-byte limit)

suggested fixes
  credentials: aws sso login   (or refresh whatever provides this profile)
  template size: no action needed: bootstrap stages large templates to S3 automatically

That transcript is a machine with an expired SSO token, which is worth showing because it is the most common way a demo fails. Note what happened: the two offline checks — world model and template size — still ran and still reported, because a template that cannot deploy is worth knowing about even with no working profile. Everything after the credentials gate was skipped, since it needs a session.

template size has two wordings and both are a normal steady state. within N bytes of, above, means the template still fits inline and has headroom. Once the estate grows past about 21 services the template crosses the 51,200-byte inline limit and the row switches to over the ... - S3 staging is required, with the percentage measured against the 1 MB hard limit instead. Neither needs action: bootstrap stages a large template to S3 by itself. See the template-size table for both forms side by side.

Reading the verdicts#

Verdict Meaning What to do
OK The check passed. Nothing.
WARN Survivable, but you should know. Read the detail; there may be no action.
FAIL This blocks injection. Run the fix. doctor exits non-zero.
SKIP Not applicable, or a prerequisite was missing. Usually resolves once an earlier check passes.

Every FAIL and every actionable WARN prints its own fix under suggested fixes, keyed by check name. doctor exits 1 if any check failed, so it works in a script:

proteus doctor --region us-west-2 && proteus seed --days 7

With working credentials the report also covers transaction search, log retention, log groups, stack, model vs stack and bedrock. Each of those has an entry in Troubleshooting.

Useful options:

proteus doctor --region eu-west-1          # check a different region
proteus doctor --profile my-sso-profile    # use a named AWS profile
proteus doctor --days 7                    # assume a 7-day backfill, not 14
proteus doctor --model /tmp/scratch.json   # check a non-default world model

--days matters: the log retention check compares each group's retention against the horizon you say you intend to seed. Passing --days 7 when you plan a 14-day seed will pass a check that should have failed.

Journey 1: first run end to end#

Goal. From an empty machine to a CloudWatch console with two weeks of history.

Steps. Four commands, in this order. The order is not decorative: bootstrap needs a model to derive log groups from, and seed needs the log groups to exist.

1. plan — build the world model#

proteus plan
wrote northwind-rx to /Users/you/.proteus/world.json: 18 services, 8 infra nodes, 3 tenants
next: proteus bootstrap then proteus seed

Warning plan overwrites the world model at ~/.proteus/world.json. If you have edited the estate with chat or in the GUI, that work is the world model. Running plan again without --force refuses; with --force it backs the old one up first, but the backup is a timestamped file you then have to find.

The refusal looks like this:

/Users/you/.proteus/world.json already exists. Pass --force to replace it (the current
model is backed up first).

The seven presets#

--preset takes one of seven. The counts below are what plan printed for each:

Preset Services Infra Tenants Use this when
web-server 1 1 1 You want one thing on the service map. One nginx on EC2, nothing else to read.
alb-fleet 3 2 1 You want one sick host to stand out against nine healthy peers. An ALB, a ten-instance fleet, a database.
three-tier 5 3 1 You are showing a fault crossing tiers. An ALB, a web tier and an app tier, with the app tier calling both a cache and a Postgres database.
ai-chatbot 6 3 1 The subject is generative AI. Browser to an API to a Bedrock agent, with a vector store and a session table behind it.
ml-pipeline 8 1 1 The workload is batch, not request-driven. Kinesis, Firehose, S3, EMR, Step Functions, a SageMaker endpoint and Athena, orchestrated by MWAA. Recommends a 300-second interval.
minimal 3 1 1 A smoke test. web to api to db and nothing else — the smallest estate that still has a dependency chain to trace.
northwind-rx 18 8 3 Default. You need somewhere for an incident to hide: every archetype, three tenants, shared infra.
proteus plan --preset three-tier
proteus plan --preset northwind-rx   # the default

The five named shapes exist because northwind-rx is a lot to take in and more telemetry than someone evaluating the tool wants to pay for. They are deliberately less interesting: most are single-tenant with little shared infrastructure, so the noisy-neighbor and aggressor scenarios have nowhere to happen. Reach for northwind-rx when you need an estate rich enough to hide an incident in.

minimal writes a model named proteus-minimal. Every other preset is named after itself.

alb-fleet is three services rather than twelve because the fleet is one service with instances=10. That is what a target group is, and it is what makes a single unhealthy host legible against nine peers instead of one name among ten. tests/test_templates.py::test_alb_fleet_is_ten_instances_not_ten_services pins it.

Instances are not a record multiplier, so the table's service count is the one to read when you are guessing at cost. instances moves metric values that describe a pool — replicas in service, desired against running tasks, nginx worker processes — rather than changing how many records get emitted. alb-fleet carries twelve instances to minimal's five and projects fewer datapoints than it.

Every preset is one connected dependency graph. A set of unrelated workloads renders as islands in Omni and blast-radius tracing stops working, so tests/test_templates.py fails a template that is not connected.

ml-pipeline is the one preset that asks for a different sampling interval, and it says so in its own next: line rather than leaving you to find out:

wrote ml-pipeline to /Users/you/.proteus/world.json: 8 services, 1 infra nodes, 1 tenants
next: proteus bootstrap then proteus seed --interval 300
ml-pipeline is shaped for 300s sampling; pass --interval to choose your own.

One minute suits request-driven workloads, where a fault's onset matters and a five-minute average would smooth it away. It is waste on a batch pipeline whose stages run for tens of minutes, and five minutes is also the grid the real services report on. It is a recommendation, not a constraint: --interval always wins, and halving the interval doubles the datapoints and the cost.

The seed is 1337 by default and identical seeds produce identical telemetry, so --seed is how you get a different-looking estate with the same shape.

Confirm it worked. proteus show prints the estate. proteus doctor reports the model, its revision and its fingerprint.

2. bootstrap — deploy the stack#

proteus bootstrap

This deploys a CloudFormation stack named proteus-<model-name>. For the default estate that is 47 resources: 18 log groups, 27 alarms, one SNS topic and one RUM app monitor. One log group per service always, plus the SNS topic and the RUM monitor; the alarm count is not a multiple of the service count, because a service only gets an alarm when its archetype emits a metric the alarm can name. On this estate 15 of the 18 services are alarmed and three are not.

Look at the template before you deploy it. --print is offline and touches nothing:

proteus bootstrap --print --model /tmp/scratch.json

The template declares one parameter, LogRetentionDays, and eleven outputs for the northwind-rx estate — including WorldModelFingerprint, which is how doctor later tells you the estate has moved ahead of what was deployed, and RumAppMonitorId, without which RUM events are counted but not sent. An estate with no browser workload has nine outputs, since the two RUM ones are absent. The rest resolve to:

MetricNamespace = proteus/northwind-rx
LogGroupPrefix  = /proteus/northwind-rx/
LogGroupCount   = 18
ProteusVersion  = 0.1.0

LogGroupCount tracks the service count, so it reads 18 on a fresh plan and 22 once four more services have been added.

Warning Retention shorter than your backfill horizon silently loses data. CloudWatch Logs returns HTTP 200 for a backdated event whose timestamp predates the group's retention, then discards it. bootstrap refuses rather than let that happen:

refusing to synthesise a template that would silently lose telemetry.
LogRetentionDays=1 days is shorter than the 14-day backfill horizon; events older
than the retention period are accepted with HTTP 200 and then silently discarded.
Raise retention to at least 14 days.

That was produced by proteus bootstrap --print --log-retention-days 1 --seed-days 14. The refusal happens at synthesis, before any AWS call.

If you intend to seed less than 14 days, tell it both numbers:

proteus bootstrap --log-retention-days 7 --seed-days 7

Confirm it worked. bootstrap prints the stack status and then next: proteus doctor to confirm prerequisites. Do that.

3. doctor — confirm before you spend#

proteus doctor

With the stack deployed you want OK on credentials, transaction search, log groups and log retention before going further. A FAIL on any of them means the next step will reject some or all of its batches. template size reporting WARN is expected on any estate and needs no action either way — bootstrap stages large templates to S3 by itself.

4. seed — backfill the history#

Cost This is the first command that spends money. A 14-day seed of the 18-service estate a fresh plan gives you, at a 60-second interval, projects about $153 at us-west-2 list price. A larger estate costs more — see Journey 6 before running it.

Size it first, without sending anything:

proteus seed --days 0.01 --dry-run --yes
                      sent
 signal  ┃ accepted ┃ rejected ┃ requests ┃  MB
━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━
 logs    │    1,397 │        0 │        0 │ 0.0
 metrics │    6,977 │        0 │        0 │ 0.0
 rum     │      361 │        0 │        0 │ 0.0
 traces  │    5,940 │        0 │        0 │ 0.0
elapsed 0.1s (dry run - nothing sent)

--days 0.01 is about fifteen minutes of history, which keeps the numbers small enough to read. requests is 0 and MB is 0.0 because nothing was batched for the wire: the recording emitter counts records and stops there.

Only traces is stable at 5,940. Across three consecutive runs logs landed between 1,396 and 1,398, metrics between 6,973 and 6,992, and rum on 360 or 361 — the diurnal wobble described at the top of this page. Do not treat a one-count difference as a bug.

Then the real run:

proteus seed

At the default estimate-confirm guard it prints a projection panel and asks Send this telemetry? [y/N]. Answer n and nothing is sent. Answer y and it sends, then prints the same table with real requests and MB, followed by:

CloudWatch indexes newly ingested telemetry asynchronously; allow a few minutes
before it is queryable in Omni.

Confirm it worked. rejected is 0 on every row and no error: lines appear. Then wait a few minutes and open the CloudWatch Omni service map. If rejected is non-zero, the error strings name the cause and Troubleshooting covers each of them.

Common variations:

proteus seed --days 7                 # half the history, half the cost
proteus seed --interval 300           # 5-minute samples instead of 60-second
proteus seed --yes                    # skip the confirmation prompt
proteus seed --region eu-west-1 --profile my-profile

Warning Pass --region consistently. It is set on the boto3 session and used to build the OTLP endpoint URLs. Mixing regions between the two puts your log groups in one region and your logs in another, and every log batch fails with HTTP 400. See region split-brain.

import-cfn: an estate from your own template#

plan gives you a fictional estate. import-cfn builds one that matches a stack you already own, so the replica has your service names, your dependency graph and your instance counts.

proteus import-cfn storefront.yaml --dry-run

It makes no AWS call. It is static analysis of a file: it reads the template, decides which resources emit telemetry, recovers the dependency graph from DependsOn and Ref/GetAtt, sizes what the properties describe, and writes a world model. Your template is never modified. There is no --profile flag, which is the flag-level consequence of that.

--dry-run prints the report and writes nothing. Drop it to write the model, and pass --force to replace an existing one.

A worked example#

This template is small enough to read and exercises every part of the report — four mappable workloads, one deliberately unmapped, and the usual scaffolding:

AWSTemplateFormatVersion: '2010-09-09'
Description: A small storefront, enough to show what the importer does.

Resources:
  StoreLb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: application
      Scheme: internet-facing
      Subnets: [!Ref SubnetA, !Ref SubnetB]

  StoreLbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref StoreLb
      Port: 443
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref WebTargets

  WebTargets:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Port: 8080
      Protocol: HTTP

  WebFleet:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: '2'
      MaxSize: '6'
      DesiredCapacity: '4'
      TargetGroupARNs: [!Ref WebTargets]

  OrdersDb:
    Type: AWS::RDS::DBInstance
    Properties:
      Engine: postgres
      DBInstanceClass: db.r6g.xlarge
      AllocatedStorage: '200'
      MultiAZ: true

  SessionCache:
    Type: AWS::ElastiCache::CacheCluster
    Properties:
      Engine: redis
      CacheNodeType: cache.r6g.large
      NumCacheNodes: 2

  InspectionLb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: gateway
      Subnets: [!Ref SubnetA]

  SubnetA:
    Type: AWS::EC2::Subnet
    Properties: { CidrBlock: 10.0.1.0/24, VpcId: !Ref Vpc }
  SubnetB:
    Type: AWS::EC2::Subnet
    Properties: { CidrBlock: 10.0.2.0/24, VpcId: !Ref Vpc }
  Vpc:
    Type: AWS::EC2::VPC
    Properties: { CidrBlock: 10.0.0.0/16 }
  WebRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement: [{ Effect: Allow, Action: 'sts:AssumeRole',
                      Principal: { Service: ec2.amazonaws.com } }]

Run it and you get this. The report is the point of the command — read it before you trust the estate:

╭────────────────────── import-cfn ──────────────────────╮
│ storefront.yaml                                        │
│ 4 services, 3 infra nodes  region us-west-2  seed 1337 │
│ 6 scaffolding resource(s) ignored, 1 unmapped          │
╰────────────────────────────────────────────────────────╯
                                                        mapped
 logical id   ┃ resource type                        ┃ service       ┃ archetype / kind  ┃ inst ┃ infra
━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━
 OrdersDb     │ RDS::DBInstance                      │ orders-db     │ rds-postgres      │ 1    │ rds-orders-db
 SessionCache │ ElastiCache::CacheCluster            │ session-cache │ elasticache       │ 2    │ cache-session-cache
 StoreLb      │ ElasticLoadBalancingV2::LoadBalancer │ store-lb      │ alb               │ 1    │ lb-store-lb
 WebFleet     │ AutoScaling::AutoScalingGroup        │ web-fleet     │ autoscaling-group │ 4    │ -
                                   NOT mapped - no telemetry is generated for these
 logical id   ┃ resource type                             ┃ why
━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 InspectionLb │ AWS::ElasticLoadBalancingV2::LoadBalancer │ Gateway Load Balancer publishes a different metric set
              │                                           │ from NLB and proteus has no profile for it; rendering it
              │                                           │ as an NLB would put plausible numbers under the wrong
              │                                           │ metric names
1 resource(s) are absent from the estate. Any alarm or dashboard that depends on them is not being exercised by this
replica.
ignored 6 scaffolding resource(s) across 5 type(s): EC2::Subnet x2, EC2::VPC x1, ElasticLoadBalancingV2::Listener x1,
ElasticLoadBalancingV2::TargetGroup x1, IAM::Role x1. These are roles, networking, listeners and observability
resources, which emit no workload telemetry of their own.
sized from your template
  OrdersDb: vcpu=4 and mem_gib=32 derived from the DBInstanceClass name 'db.r6g.xlarge' (size suffix and family ratio,
not the EC2 catalogue)
  OrdersDb: storage_gib=200 from AllocatedStorage
  OrdersDb: max_connections=400 derived from memory using the RDS default parameter group formula
  SessionCache: vcpu=2 and mem_gib=16 derived from the CacheNodeType name 'cache.r6g.large' (size suffix and family
ratio, not the EC2 catalogue)
  SessionCache: instances=2 from NumCacheNodes
  WebFleet: instances=4 from DesiredCapacity
defaulted (the template did not say, or said something unresolvable)
  OrdersDb: instances defaulted to 1: the template states no node count
1 service(s) are sized entirely from archetype defaults: store-lb. Their request rates and latencies are proteus's,
not your stack's.
topology 1 edge(s): 0 taken straight from a reference or DependsOn, 1 with the direction inferred from tier order
because the template states the link but not which way the calls go.
  note: the derived estate is 3 disconnected groups, not one topology: orders-db; session-cache; store-lb+web-fleet.
CloudWatch Omni builds topology from dependency edges, so these will draw as islands. No edges were invented to join
them - that would put a call in your service map that your template does not describe.
--dry-run: nothing written

The three buckets#

Every resource lands in exactly one, and the distinction is the whole value of the report.

Mapped. 69 resource types become a service, an infra node, or both. These are what emits telemetry.

Ignored. 212 enumerated types, plus eight type prefixes, are known scaffolding — roles, subnets, listeners, target groups, observability resources. They are counted by type rather than listed, because a wall of AWS::IAM::Role x14 buries the row that matters.

Unmapped. Named individually, each with a reason, and followed by the consequence in plain words: any alarm or dashboard that depends on them is not being exercised by this replica. That sentence is the one to act on. An unmapped resource does not just mean a missing node on the map — it means a monitor you believe you tested is untested.

--strict exits non-zero when anything is unmapped, which makes the command usable as a CI gate:

proteus import-cfn storefront.yaml --strict --dry-run

Why Gateway Load Balancer is refused#

The unmapped row above is deliberate, and it is the clearest illustration of how this tool treats uncertainty. A Gateway Load Balancer is an AWS::ElasticLoadBalancingV2::LoadBalancer — the same resource type as the ALB two rows above it, distinguished only by Type: gateway. Mapping it to the NLB profile would have been one line and would have produced entirely plausible charts, under metric names GWLB does not publish. It is left out instead, loudly. A gap you can see beats a number you cannot check.

Sizing says where every number came from#

The report splits sizing in two. sized from your template lists each number and the property it came from. defaulted lists what the template did not say, so the figure is the archetype's rather than yours — including a Ref the importer could not resolve, which is reported with its reason rather than silently treated as absent.

Read the vCPU and memory notes literally: derived from the DBInstanceClass name 'db.r6g.xlarge' (size suffix and family ratio, not the EC2 catalogue). The numbers come from parsing the instance-class string, not from looking the class up. That is approximate at the edges and the report says so every time rather than once.

Nested stacks, and the path it will not open#

A child stack is expanded only when you name it:

proteus import-cfn infra/root.yaml \
  --include NetworkStack=infra/network.yaml \
  --include AppStack=infra/app.yaml \
  --include AppStack/WorkerStack=infra/worker.yaml

LogicalId/ChildId reaches a grandchild. The template's own TemplateURL is never read, and that boundary is structural rather than a promise: the import module has no filesystem access at all, so it cannot follow a path even by mistake. A path taken out of a document someone sent you is not a path this tool will open.

The parser has an enumerated boundary#

Templates are read by a hand-written parser rather than a YAML dependency — proteus/model/cfnimport/yamlish.py, whose docstring is the authoritative list of what it does and does not accept. It understands all 21 intrinsic short forms (!Ref, !GetAtt, !Sub, !If and the rest), in inline, block and flow positions, and normalises each into its long form so JSON and YAML take one code path downstream.

Thirteen constructs are rejected with a line number rather than guessed at: anchors, aliases and merge keys; explicit !! type tags; an unknown !Tag; a second document; tab-indented structure; an unterminated flow collection or quoted string; a duplicate key; a missing key: colon; a top level that is not a mapping; and an empty template.

The refusals are loud on purpose. A template that is silently mis-read produces an estate quietly missing services — and then an alarm test that passes for the wrong reason. A loud refusal costs a minute; a wrong parse is not recoverable, because nothing downstream looks wrong. Every failure suggests converting the template to JSON, which sidesteps every row on that list.

Note import-cfn writes a world model and nothing else. Everything after it is the normal path: bootstrap to create the log groups and alarms, doctor to check, then seed. The imported estate costs the same to run as any other of its size.

import-live: an estate from a real account#

import-cfn's sibling. Where that one reads a template, this reads a running AWS account and clones it — topology from the resources it finds, and baselines from what CloudWatch has actually recorded. Someone with a template uses import-cfn; someone with an account uses this.

proteus import-live --region us-west-2 --profile my-profile --discover-only

Warning Start with --discover-only, and read the next section before you trust a single baseline. This command has a failure mode that does not appear in its output: it can ask CloudWatch the wrong question and report the answer as though your monitoring had a gap.

The limitation that is not visible in the output#

The metric names and dimensions import-live asks for were written from AWS documentation, not verified against a live response. A wrong dimension name does not raise. It returns an empty series, the absent-metric logic then correctly reports absent, and the report correctly says the metric was never published — so a busy service reads as unmonitored. Every layer is telling the truth about what it saw. The result is still wrong.

That is not a caveat about precision. It is a specific way to be misled, and the consequence is concrete: if you read absent as "nothing was monitoring this" and build an alarm test on it, you have tested nothing, and the tool told you so in words that meant something else.

Verify it on your first run against any account, before you rely on the output. Three steps, and the first two are free:

# 1. free: what is out there, no metric reads at all
proteus import-live --region us-west-2 --profile my-profile --discover-only

# 2. one narrow, cheap read against an account whose answer you already know
proteus import-live --region us-west-2 --profile my-profile \
  --services elbv2,lambda --days 1

Then read the outcome column. An absent against a service you know is busy means this command asked the wrong question — not that your monitoring has a hole. Do that on a non-production account where you know the answer, not on the customer account you are trying to replicate.

Read-only, structurally#

Only Describe, List and Get calls are made, and that is enforced rather than promised. Every client a probe touches is wrapped in a ReadOnlyClient that allows a method only if its name starts with one of ten read prefixes — describe_, list_, get_, batch_get_, lookup_, select_, search_, query_, scan_, head_ — and raises ReadOnlyViolation naming the method for anything else. The refusal happens before boto3 sees the call, and tests/test_live_baselines.py asserts the refusal rather than trusting it.

The package also never imports boto3 at all: the session is handed in through a one-method Session protocol. So the boundary is the same shape as import-cfn's refusal to read TemplateURL — a thing the code cannot do, rather than a thing it has been told not to do.

Nothing in your account is created, modified, tagged or deleted. Note that --profile is present on this command, unlike import-cfn: that flag is the tell that this one really does call AWS.

Cost is disclosed in two phases#

The dollar figure cannot exist until discovery has run, because it depends on how many metrics your account turns out to have. So there are two disclosures, each confirmed separately.

Phase one — discovery, free. It names every API operation it may call and states that all of them are free. With the default service set that is 33 operations across 15 probes; the list is filtered by --services, so --services elbv2,lambda narrows it to 9. The list is derived from the probe registry rather than written out, so it cannot drift from what the code actually calls.

Phase two — the CloudWatch read, billed. Before any GetMetricData, it states the exact metric count and the cost as metrics / 1000 × $0.01, at public list price. That is an upper bound: the most expensive tier, free-tier allowance ignored. It is not a quote, and an account with a discount program or unused free tier pays less.

--discover-only stops between the two, so it costs nothing and every baseline is an archetype default. --yes answers both disclosures — read them first.

Cost GetMetricData is the only billed call this command makes, and it reads rather than writes. This is the one place in Proteus where money is spent reading instead of injecting.

Four baseline outcomes, not two#

This is the most useful thing in the feature. A metric with no datapoints is not a zero, and the report keeps the difference:

Outcome What it means What the replica does with it
observed Datapoints in the window. A real number. Uses it.
idle The metric exists and the resource was idle — a real observation of zero. Keeps the zero.
absent The metric was never published. Not an observation of anything. Falls back to an archetype default and says so.
failed The call did not succeed. "I could not check" is not "it is not there." Falls back, and reports the failure separately.

The absent and failed split is the one to internalize. Collapsing either into a zero would put a confident 0 in your replica where the truth is "unknown", and a downstream alarm test would then pass on a number nobody measured.

An idle series is used for a count and never for a latency. If a service served no requests, a measured zero is the right request rate — but there was no latency, so a 0 ms p99 would be fabricated. Idle latencies fall back to the archetype default instead.

A p99 derived from Average says so. The p99 extended statistic is unavailable for every metric AWS publishes as a pre-aggregated statistic set, and in that case the figure is the average times 2.5. That multiplier is a rule of thumb and nothing more, so every number derived through it is labelled.

Useful options#

proteus import-live --services rds,lambda,elbv2   # probe a subset; the rest is absent
proteus import-live --days 1                      # shorter CloudWatch window, cheaper
proteus import-live --no-census                    # skip the tagging-API census
proteus import-live --strict --dry-run             # for CI: non-zero if anything failed

One region per run. --services takes any of apigateway, autoscaling, dynamodb, ec2, ecs, elasticache, elbv2, kinesis, lambda, network, opensearch, rds, redshift, sns, sqs. Anything not probed is absent from the replica and reported as such rather than silently missing.

--no-census skips the tagging-API census and probes every selected service directly. It is slower, and it is the only way to reach a resource the tagging API does not return.

The estate name defaults to live-<account>-<region>. --dry-run prints the report and writes no model.

Note Like import-cfn, this writes a world model and nothing else. Everything after it is the normal path — bootstrap, doctor, then seed. The replica emits synthetic telemetry shaped like your account; it does not forward anything real.

Journey 2: reading the estate#

Goal. Know what is modeled before you inject it or demo it.

All three commands here are offline and read-only.

show#

proteus show

Straight after plan, that is the whole default estate. Rows elided with ... where the pattern repeats; nothing else is trimmed.

╭─────────────────────────────────────────────────────────────────╮
│ northwind-rx rev 1  region us-west-2  env production  seed 1337 │
│ fingerprint d7caf912a5a9862f                                    │
╰─────────────────────────────────────────────────────────────────╯
                                    services
 name               ┃ archetype     ┃ runtime ┃ tenant  ┃ inst ┃ rps ┃ depends on
━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━━━━━
 storefront-web     │ browser-rum   │ browser │ -       │ 1    │ 90  │ edge-alb
 edge-alb           │ alb           │ managed │ -       │ 1    │ 260 │ api-gateway
 api-gateway        │ nginx-web     │ ec2     │ -       │ 4    │ 250 │ checkout, search,
                    │               │         │         │      │     │ account, rx-advisor,
                    │               │         │         │      │     │ rx-refills
 checkout           │ app-service   │ eks     │ t-alpha │ 6    │ 110 │ cart, payments, orders
 cart               │ app-service   │ eks     │ t-alpha │ 4    │ 140 │ session-cache
 payments           │ app-service   │ ecs     │ t-alpha │ 3    │ 70  │ event-bus
 orders             │ app-service   │ eks     │ t-alpha │ 5    │ 95  │ orders-db
 search             │ app-service   │ eks     │ t-gamma │ 4    │ 130 │ catalog-search
 account            │ app-service   │ eks     │ t-gamma │ 3    │ 60  │ orders-db
 rx-refills         │ app-service   │ eks     │ t-beta  │ 3    │ 55  │ orders,
                    │               │         │         │      │     │ session-cache
 inventory-consumer │ eks-workload  │ eks     │ t-alpha │ 4    │ 180 │ event-bus, orders-db
 claims-batch       │ ecs-workload  │ ecs     │ t-beta  │ 2    │ 8   │ orders-db
 notify             │ lambda        │ lambda  │ -       │ 1    │ 45  │ event-bus
 rx-advisor         │ bedrock-agent │ lambda  │ t-alpha │ 2    │ 14  │ search, orders
 orders-db          │ rds-postgres  │ managed │ -       │ 1    │ 320 │ -
 session-cache      │ elasticache   │ managed │ -       │ 2    │ 420 │ -
 catalog-search     │ opensearch    │ managed │ -       │ 3    │ 150 │ -
 event-bus          │ kafka         │ managed │ -       │ 3    │ 260 │ -
                                     infra
 id            ┃ kind              ┃ shared by       ┃ capacity
━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 lb-edge       │ load-balancer     │ all tenants     │ lcu=200
 rds-orders    │ rds-instance      │ t-alpha, t-beta │ iops=3000,
               │                   │                 │ max_connections=200, mem_gib=32,
               │                   │                 │ storage_gib=500, vcpu=8
 cache-session │ cache-cluster     │ all tenants     │ mem_gib=13, nodes=2, vcpu=2
 ...
 ebs-data      │ ebs-volume        │ all tenants     │ iops=3000, storage_gib=200
no active faults

Four things to notice.

rev 1 and fingerprint d7caf912a5a9862f are how you tell whether the deployed stack still matches the model. doctor compares that fingerprint against the stack's WorldModelFingerprint output. Every edit bumps the revision; the fingerprint covers the estate but not the faults, so adding or moving a fault changes rev and leaves the fingerprint alone.

no active faults is the honest empty state, not a missing section. plan writes no faults, so a fresh estate has none until you add one — with chat, in the Web GUI, or via hunt. Once one exists, a faults table appears under the infra table with its id, kind, target, severity and window.

Every service here uses one of the twelve hand-written archetypes. A thirteenth, custom, appears in the archetype column once you add an AWS resource profile — that is the archetype that delegates to a workload profile rather than to bespoke code, and it is what all 56 AWS resource types come through.

shared by on an infra node is what makes noisy-neighbor possible: rds-orders is shared by t-alpha and t-beta, so one tenant can starve the other.

If a hunt is open, show does not print its fault. It prints this instead:

1 hidden fault(s) active from an open hunt - details withheld. Use
proteus hunt reveal <id> to see them.

That redaction is deliberate. The first live hunt printed its own answer through this table — kind, target, severity and window — which defeats the whole mode.

faults#

proteus faults
 kind                       ┃ signature
━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 queue-depth                │ Consumers fall behind: lag and queue depth climb, latency
                            │ rises moderately, errors flat.
 page-load-latency          │ Front-end page loads slow sharply with a flat error rate.
 cpu-saturation             │ CPU pinned near 100%: latency degrades superlinearly, with
                            │ throttling in the logs.
 oom-kill                   │ Memory exceeds the limit, pods are OOM-killed and restart;
                            │ traffic dips in bursts.
 disk-full                  │ The volume fills and writes fail with ENOSPC.
 noisy-neighbor             │ A co-tenant saturates shared infrastructure; victims slow
                            │ with no local cause.
 connection-pool-exhaustion │ All pool connections are checked out: callers queue, then
                            │ time out waiting.
 cache-stampede             │ Cache hit ratio collapses and the miss storm multiplies
                            │ downstream load.
 agent-quality-regression   │ Agent answer quality drops while latency, errors, CPU and
                            │ tokens stay healthy.
 dependency-timeout         │ A downstream call hangs to its timeout ceiling, then errors.
 network-packet-loss        │ Packets drop; retries stretch latency and leak a moderate
                            │ error rate.
 slow-query                 │ A query plan regresses: latency only, with flat CPU and
                            │ flat errors.
 shard-hotspot              │ Load skews onto one shard: it runs hot and rejects work
                            │ from a full queue.
 thread-pool-rejection      │ The work queue is full: errors dominate while latency
                            │ barely moves.

The signature column is the point of the command: it tells you what an operator would actually see, which is what makes each fault distinguishable in a hunt. Read them as a set — slow-query is latency with flat CPU and flat errors, while thread-pool-rejection is errors with barely-moved latency. The Appendix lists each one's drop target and tunable parameters.

about#

proteus about
  version    0.1.0
  state      /Users/you/.proteus
  region     us-west-2
  docs       README.md · CONTRACTS.md · WEBAPI.md

Use it to confirm which state directory is in play. That state line is the PROTEUS_HOME override resolved — if it does not say what you expect, the environment variable is set somewhere you forgot about, and every other command is reading a different world model than you think.

rename: renaming without breaking the graph#

A name in the world model is a foreign key, not a label. Service.depends_on is a list of Service.name; Fault.target holds a Service.name or an InfraNode.id. So renaming an object means rewriting every reference to it, and missing one silently loses an edge — the renderer draws a service with no callers, faults stop propagating through it, and nothing raises.

proteus rename does the whole graph edit. It is offline: no AWS call, no cost.

proteus rename '*' 'webserver00' --dry-run
'webserver00': literal 'webserver'; counter zero-padded to 2. Example: webserver01, webserver02,
webserver03, ....
  storefront-web  ->  webserver01
  edge-alb  ->  webserver02
  api-gateway  ->  webserver03
  checkout  ->  webserver04
  cart  ->  webserver05
  payments  ->  webserver06
  orders  ->  webserver07
  search  ->  webserver08
  account  ->  webserver09
  rx-refills  ->  webserver10
  inventory-consumer  ->  webserver11
  claims-batch  ->  webserver12
  notify  ->  webserver13
  rx-advisor  ->  webserver14
  orders-db  ->  webserver15
  session-cache  ->  webserver16
  catalog-search  ->  webserver17
  event-bus  ->  webserver18
  18 rename(s), 23 reference edit(s): 23 depends_on, 0 fault target(s)
--dry-run: nothing written

The first line is the pattern explained back to you before anything happens, and the last counts reference edits, not objects. That distinction is the useful part: renaming one service with three callers is one rename and three edits, so a plan reporting zero edits against a service you know is connected is a bug you want to see before applying it, not after. The reviewable number is the one that would look wrong if the thing were broken.

fault target(s) reads zero in both transcripts here only because a fresh plan carries no faults — the presets ship with none. Once faults exist, renaming their target bumps that count, and it is the number to watch: Fault.target is the reference most easily missed, because unlike depends_on it lives in a single field shared with another namespace.

--dry-run prints exactly the plan the confirmation prompt shows — deliberately the same rendering, because two renderings of one fact drift apart and the less-read one rots.

Selectors are globs or slash-wrapped regexes, and the appendix has the grammar:

proteus rename '/^(orders|cart)$/' 'svc-{n:02}' --dry-run
'svc-{n:02}': literal 'svc-'; counter zero-padded to 2. Example: svc-01, svc-02, svc-03, ....
  cart  ->  svc-01
  orders  ->  svc-02
  2 rename(s), 4 reference edit(s): 4 depends_on, 0 fault target(s)
--dry-run: nothing written

Add --infra to rename infrastructure nodes instead, and --yes to skip the prompt.

Two refusals worth understanding before you hit them#

A new name that collides with an object the rename does not touch. Renaming cart to checkout while checkout still exists would produce two services with one name:

rename refused: 1 new name(s) are already used by service objects this rename does not touch
(checkout). Widen the selector or choose a different pattern.

"Widen the selector" is the actual fix, and it is why the refusal is narrower than it looks. Colliding with a name that is being renamed away in the same operation is fine — permuting a set of names is the commonest bulk rename there is. Put both objects in the selector and it applies:

proteus rename '/^(cart|checkout)$/' 'svc-{n:02}'    # legal: both are moving

A name that exists in both namespaces. Fault.target carries a service name or an infra node id in one field, so if a service and an infra node share a name, rewriting every target equal to the old name would move the other object's faults too — silently, with the model still validating afterwards:

rename refused: 1 name(s) exist as BOTH a service and an infra node (rds-orders), so a Fault.target
rewrite cannot be attributed to one of them. Rename one of the two by hand first.

It refuses and names the clash rather than guessing. This only arises on a model where the two namespaces have been made to overlap; the presets never do.

Journey 3: a live demo#

Goal. Telemetry arriving with current timestamps while you talk over it.

Cost live and firehose both send real data. Cost scales with duration and with how dense the interval is. Project it first with --dry-run.

live while you narrate#

proteus live --minutes 30 --interval 60 --tick 30

Three numbers, and they are easy to confuse:

Option Meaning Default
--minutes How long the whole run lasts before it stops. 30
--interval Sampling interval of the synthesized data, in seconds. 60
--tick How often a send cycle fires, in seconds. 30

So the default streams for half an hour, sending every 30 seconds, and each send covers the seconds since the previous cycle at one sample per minute.

Each cycle prints a line, which is what you want on a projector:

cycle 1: 4,812 records sent so far
cycle 2: 9,640 records sent so far

The guard is evaluated once, at the start, against a projection of the whole run — not per cycle. So you confirm the cost of thirty minutes once, and then it streams.

Ctrl-C stops it cleanly. The run reports stopped by user and prints the totals it managed to send; nothing is left half-written, because each cycle is a complete set of batches.

Confirm it worked. The per-cycle counter climbs, rejected is 0 in the final table, and the service map in Omni shows the current minute. Allow a few minutes of CloudWatch indexing lag before you point at a chart.

firehose: watching data arrive#

proteus firehose --minutes 10

firehose is live with a much denser interval — the defaults are --minutes 10 --interval 10 --tick 10, so it samples every ten seconds and sends every ten seconds. Use it when someone wants to see data land rather than see a populated console.

It self-throttles below the documented per-account ceilings — 500 requests/second for metrics, 5,000 for logs — so the run slows down instead of collecting HTTP 429s and leaving gaps in your charts. That matters for a demo: a throttled run with holes in it looks like a broken product.

Cost A denser interval means proportionally more records for the same wall clock, so firehose costs more per minute than live. Ten minutes at a ten-second interval is six times the data of ten minutes at sixty.

Journey 4: injecting an incident with chat#

Goal. Add a specific incident — or a specific service — by describing it, without hand-editing JSON.

proteus chat "add a connection pool exhaustion fault on orders lasting 45 minutes"

Omit the message for a REPL:

proteus chat
describe a change, or blank line to exit
proteus>

A blank line exits. --region selects the region for both Bedrock and the estate; --profile selects the AWS profile.

The critical property: the AI edits the world model, never the data. Bedrock proposes structured edits to the model. The renderer then produces telemetry from that model deterministically. Nothing an LLM says reaches a metric, a span or a log line directly, which is why output stays reproducible from the model plus the seed. Two consequences you can rely on:

  • The same model and seed always render the same telemetry, whatever the chat history was.
  • A change the model cannot represent is rejected rather than approximated. Each rejection prints as rejected: <reason> and the model is left alone.

What it can actually change#

Nine tool functions, and that list is the whole vocabulary. Anything you ask for that does not reduce to one of these is refused:

Tool What it does
add_service Add one service, with its archetype, runtime, tenant, dependencies and baseline numbers.
remove_service Remove a service, and detach it from other services' dependencies.
add_infra Add a contended infrastructure node that services share, with its capacity.
add_fault Inject a time-bounded fault against a service or infra node.
remove_fault Remove a fault by id.
adjust_baseline Change the steady-state latency, error rate or request rate of an existing service.
add_tenant Add a fictional customer sharing the estate.
describe_estate Read-only summary of the estate. Changes nothing.
random_mutation One plausible, interesting, reproducible change, optionally nudged by a hint.

Two boundaries worth knowing before you phrase a request. There is no remove_infra and no remove_tenant — infrastructure and tenants can be added by chat but only removed in the GUI or by editing the model. And remove_fault cannot touch a hidden fault from an open hunt, which is part of how a hunt's answer stays sealed.

describe_estate is the one to reach for when you are not sure what you are editing: it costs a Bedrock call and changes nothing, and it tells you the names the other eight tools expect.

Each accepted turn backs up the previous model, writes the new one, and prints a panel summarizing what changed. So proteus show afterwards is how you check the edit landed the way you meant.

This is how an estate acquires services the preset never had. Ask for an SQS queue, a DynamoDB table or a CloudFront distribution and add_service writes one with archetype=custom and the profile named in its kind, which is then what show reports in the archetype column. The revision bumps and the fingerprint changes, so doctor will tell you the estate has moved ahead of the deployed stack — re-run bootstrap before you seed, or the new service has no log group to write to.

Note chat needs Amazon Bedrock model access in the region you pass. doctor's bedrock check reports whether it can reach a model, and PROTEUS_MODEL overrides which one it uses. chat changes the world model on disk; it sends no telemetry and costs nothing beyond the Bedrock inference.

The transcript of a real chat turn is not reproduced here — the run that would have produced it needed live Bedrock credentials, which were not available when this page was captured. The behavior above is read from proteus/ai/chat.py and proteus/cli/interact.py; the wording of the summary panel is the model's and varies per turn.

Journey 5: a fault-finding exercise with hunt#

Goal. Give someone a broken estate and no answer key, then grade their diagnosis.

Four subcommands:

proteus hunt start --difficulty medium   # inject a hidden fault, print a briefing
proteus hunt list                        # list hunts, never the answers
proteus hunt guess <hunt-id> "..."       # submit a diagnosis, get scored
proteus hunt reveal <hunt-id>            # show the answer and the evidence

Starting one#

proteus hunt start --difficulty hard

start picks a fault with Bedrock, appends it to the world model with hidden=True, seals the answer in ~/.proteus/hunts/, and prints a briefing plus the hunt id. It then tells you to inject it:

inject it with proteus seed --days 1 or proteus live, find it in Omni, then
proteus hunt guess <hunt-id> "..."

--difficulty takes easy, medium or hard.

Cost hunt start itself is a Bedrock call and a model edit — no telemetry, no CloudWatch spend. The seed or live that follows is what costs money.

The answer does not leak#

This is the part worth trusting, so here is exactly how it holds:

  • The fault is stored with hidden=True, and proteus show filters hidden faults out of its table entirely, printing only a count. Both the kind column and the fault id are withheld, because the id used to embed the kind.
  • chat never reads the sealed file, never receives a hidden fault in its estate digest, and cannot describe or delete one.
  • hunt list prints id, status, difficulty and window — never kind or target.
  • While the overall score is below 70 the grader's own explanation is withheld and replaced with a hint that is screened for leaks. The screen rejects the fault kind and each of its hyphen-separated parts, so a hint cannot smuggle out "slow" and "query" separately. If the model's hint fails screening, a score-derived hint is substituted that cannot leak by construction.

Grading#

proteus hunt guess hunt-20260923-18d963 "the orders database query plan regressed around 21:30"

Three axes, each scored 0–100 independently, weighted into one overall:

Axis What it scores Weight
root_cause Did you identify the failure mode? 50%
affected Did you name the right service or resource? 30%
window Did you place it in the right time window? 20%

A guess counts as solved when root_cause is at least 70 and the overall is at least 70. Overall 70 is also the disclosure threshold — below it the hunt stays open and you get a hint instead of the debrief.

The grader is generous on wording and strict on substance. "The database ran out of connections" is a full match for connection-pool-exhaustion. "It was slow" is a symptom, not a root cause, and scores zero on that axis. Naming a downstream victim instead of the true target is partial credit on affected, not zero. Anything within about 15 minutes of the real window is full marks on window.

Revealing#

reveal is offline — it reads the sealed ledger, no AWS call:

proteus hunt reveal hunt-20260923-18d963
╭──────────────────────── hunt hunt-20260923-18d963 ────────────────────────╮
│ Hunt hunt-20260923-18d963 (medium, status solved)                         │
│ Estate: northwind-rx in us-west-2                                        │
│                                                                          │
│ Fault kind:  slow-query                                                  │
│ Target:      orders-db                                                   │
│ Window:      2026-09-23T21:27:16+00:00 to 2026-09-23T22:32:16+00:00      │
│ Severity:    0.52 (fraction of full effect, 0..1)                        │
│ Params:      {}                                                          │
│ Fault id:    hidden-eb7105ac                                             │
│ Chosen by:   bedrock                                                     │
│ Rationale:   slow-query on orders-db is a subtle root cause that will     │
│ manifest one hop away in multiple dependent services (orders, account,   │
│ inventory-consumer, claims-batch). The RDS instance is a critical shared │
│ dependency handling 320rps across multiple tenants. Symptoms will appear │
│ as elevated latency in downstream services while the database itself may │
│ show only moderately increased query times, requiring correlation        │
│ analysis to identify the true source.                                    │
│                                                                          │
│ Guesses (3):                                                             │
│   ...22:12:40: overall 0 (cause 0, service 0, window 0) - wrong track    │
│   ...22:13:03: overall 30 (cause 0, service 100, window 0) - wrong track │
│   ...22:13:24: overall 100 (cause 100, service 100, window 100) - nailed │
│                it                                                        │
╰──────────────────────────────────────────────────────────────────────────╯

That guess history is the exercise working. The second attempt named the right service and nothing else: 100 on affected, 0 on the other two, weighted to 30 — below the disclosure threshold, so the hunter got a hint rather than the answer, and found it on the third try.

proteus hunt list
 id                   ┃ status ┃ difficulty ┃ window
━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 hunt-20260923-18d963 │ solved │ medium     │ 2026-09-23T21:27:16+00:00 ->
                      │        │            │ 2026-09-23T22:32:16+00:00
 hunt-20260923-ed8031 │ open   │ hard       │ 2026-09-23T19:41:37+00:00 ->
                      │        │            │ 2026-09-23T21:16:37+00:00
 hunt-20260923-43c8e9 │ solved │ medium     │ 2026-09-23T21:16:10+00:00 ->
                      │        │            │ 2026-09-23T22:21:10+00:00

Confirm the exercise is set up correctly. After hunt start, run proteus show. It must report a withheld hidden fault and must not name it. If it names the fault, do not run the exercise.

Journey 6: cost control#

Goal. Know what a run costs before it runs, and put a hard limit on it.

Size a run without sending anything#

proteus seed --days 0.01 --dry-run --yes

--dry-run forces the paranoid policy, which renders and costs everything and makes no network call at all. It prints three things in this order: the full projection panel, the PARANOID GUARD banner, and the record counts. So this is the one command that gives you every number without sending anything and without a prompt.

The counts come last, in the same table Journey 1 shows: requests 0, MB 0.0, and elapsed 0.1s (dry run - nothing sent).

Because paranoid also disables the live pricing lookup, a dry run's own cost figure comes from the built-in fallback table rather than live rates, so it can differ from the figure the same window gets at the default guard. That is deliberate — see the cost guard.

The tail of the same run, after the projection panel:

Estimated cost  ~ $140.73 USD  (approximate, not a quote)
...
PARANOID GUARD: telemetry will be rendered and costed but NOTHING will be sent to AWS.
  guard: Paranoid policy: rendering and estimating only.
  guard: estimated cost of this run: ~$140.73 USD (approximate)

The Web GUI footer shows the same projection continuously, without running anything at all.

Seeing the projection#

At the default guard, the prompt shows it:

proteus seed --days 14

For the 18-service estate a fresh proteus plan gives you, at a 60-second interval:

Projected volume
  spans                7,983,360
  metric points        9,354,240
  log events           1,895,040
  RUM events             483,840
  new metric series          469

Projected wire traffic (uncompressed)
  traces                 3.1 GiB
  metrics                1.0 GiB
  logs                 679.4 MiB
  total                  4.8 GiB
  requests               12,701

Estimated cost  ~ $152.53 USD  (approximate, not a quote)

Answer n and nothing is sent.

That figure is us-west-2 list price and it is not a quote: the Pricing API cannot see negotiated discounts, credits or private pricing, so a real invoice is normally lower.

Treat the dollars and most of the volumes as one run, not as constants. Three runs of that exact command on the untouched preset, minutes apart, gave $152.53, $152.73 and $152.74, with metric points between 9,354,240 and 9,434,880 and RUM events between 483,840 and 504,000. Spans (7,983,360) and new metric series (469) were identical every time. The window is anchored to now and the modeled traffic curve is diurnal, so shifting the window by a minute moves what falls inside it. A 0.1% spread does not matter for sizing a run; it does matter if you are trying to reproduce a number exactly.

Two figures the projection panel does not let you compare directly:

  • The rate source. The panel's warnings say whether the rates came from the live AWS Pricing API, a warm cache, or the built-in table compiled into the build. Read them before you quote the number. The built-in table is a hand-copied us-east-1 approximation and drifts from real list price in whichever direction AWS last moved a rate; the warning says as much.
  • total in bytes, and its unit. The CLI reports binary units — 4.8 GiB is 4.8 × 1024³ bytes. The Web GUI footer reports the same quantity in decimal units, so it reads 5.19 GB. Both are the same 5.19 billion bytes. The two used to both say GB, which made them look 7% apart; only the CLI's label was wrong, and no dollar figure was ever affected because the cost arithmetic multiplies the raw byte count rather than the formatted string.

Add services and every figure moves, well past that 0.1%. Quote the figure for the estate you are actually running, not the one on this page.

Choosing a guard policy#

proteus seed --guard budget-ceiling --cap 25
Policy Behavior Use it when
estimate-confirm Projects, shows it, requires an explicit yes. Default. Normally.
budget-ceiling Hard cap in USD against a persistent monthly ledger; refuses if the run would exceed it. Shared account, or a demo you will repeat.
warn-only Prints the projection and proceeds. Scripted runs you have already sized.
paranoid Renders and costs everything, sends nothing, makes no network call. Rehearsing, or verifying a change.
unlimited No spend check, no confirmation. You know exactly what you are doing.

--cap only applies to budget-ceiling and defaults to $50. The ledger lives at $PROTEUS_HOME/spend.json and records estimated — not billed — dollars, only for runs that actually sent data under budget-ceiling.

paranoid and unlimited each print a banner naming what they are doing, because both are surprising in opposite directions:

PARANOID GUARD: telemetry will be rendered and costed but NOTHING will be sent to AWS.
UNLIMITED GUARD: every projected record will be sent to AWS with no spend check and
no confirmation. You are responsible for the bill.

Warning A short run looks disproportionately expensive. The projection charges a full metric-month for every new metric series rather than prorating, and takes the most expensive pricing tier. A one-minute run of the 18-service estate projects $138–$141 — around 460 new metric series at $0.30 each — against pennies of real cost. This is deliberate: over-estimating is the safe direction. See the cost guard and the troubleshooting entry.

Journey 7: teardown#

Goal. Remove everything Proteus created, and nothing else.

proteus teardown

It prints the plan first — every resource that would be deleted, by logical id, type and physical id — then asks Delete stack proteus-<name> and everything above? [y/N]. Answer n and it prints cancelled and exits non-zero.

Warning teardown deletes the CloudFormation stack, which deletes the log groups, and that deletes the telemetry you seeded into them. There is no undo. --yes skips the prompt; do not put it in a script you might run against the wrong region.

What it refuses to delete#

Two fences, both applied by the plan as well as the delete, so a refusal surfaces before you confirm anything:

  1. The name must start with proteus-. Anything else is refused outright: refusing to touch '<name>': proteus only deletes stacks whose name starts with 'proteus-'.
  2. The stack must prove Proteus created it — either a ProteusVersion output or a proteus:managed=true tag. Without one: refusing to delete '<name>': it has neither the ProteusVersion output nor the proteus:managed=true tag, so proteus did not create it.

A stack that does not exist raises rather than silently succeeding, and the CLI prints nothing to delete (stack absent or already empty) when the plan comes back with no resources.

Staged template objects for the stack are deleted alongside it. The S3 staging bucket itself is kept — it is created with public access fully blocked and a 7-day expiry on staged objects, so leaving it costs nothing and the lifecycle rule clears any stragglers.

Confirm it worked. teardown prints deleted proteus-<name>. Then proteus doctor reports stack <name> does not exist - nothing provisioned yet as a WARN, which is the correct post-teardown state.

The world model in ~/.proteus/ is untouched by teardown. Delete that directory yourself if you want a genuinely clean machine — and note it also holds your spend ledger and sealed hunt answers.

Proteus is MIT-licensed. Built by Jeremy Hall.

Synthetic telemetry for CloudWatch Omni. Everything it emits is fabricated; nothing in it describes a real system.