Pathly watches the path. Ping if it's up. Flow if a user can walk it. Chain if the services behind it hold. This article is about the Terraform provider that declares the HTTP check, the availability target, the maintenance window and the webhook. The browser journey and the HTTP chain are real products, and they are not what this file creates. If you want the contract those resources call, read the API article. The pages a developer actually uses live on the Pathly developer docs.
The provider is published as pathlyhq/pathly on the public Terraform Registry. You do not compile it and you do not host a mirror. A version constraint plus terraform init is the install. The schema is still 0.x. That is a precise statement, not a shrug. Existing resources will not move without a reason, and a schema field can still change before 1.0. Pin ~> 0.1 so a later 0.2 does not land in a pipeline that never asked for it.
The smallest file that does something
terraform {
required_version = ">= 1.6"
required_providers {
pathly = {
source = "pathlyhq/pathly"
version = "~> 0.1"
}
}
}
provider "pathly" {}
resource "pathly_scenario" "site" {
name = "Home page"
url = "https://shop.example.com"
interval_sec = 300
expect_text = "Our products"
tags = ["prod"]
}
The provider block is empty on purpose. The API key is read from the environment variable PATHLY_API_TOKEN. If you pass that key through a Terraform variable, the value is written in clear into the state file. State is copied, backed up and sometimes committed by mistake. The environment variable stays out of the plan. The provider also refuses plain http toward anything except localhost, because the key travels in an Authorization header and every proxy on a clear-text hop can read it.
pathly_scenario with a url is a Ping. It requests that address on a timer. It does not open Chrome, it does not click, and it does not call a second URL. interval_sec = 300 means one check every five minutes. expect_text is the line that separates a live server from a live site. An application error page is very happy to answer HTTP 200. The expected text is what exposes it. tags are free labels. Later a data source filters on one of them, which is how you find checks that exist in the console and not in this file.
shop.example.com is a documentation host. Use your real origin when you apply this. Do not point a production check at a host you do not control.
What the CI smoke actually applies
The repository ships a smoke configuration that creates every managed resource, reads it back, and destroys it. It is the file to copy when you want the shapes, not a file to apply against a customer site. The identifier of the run is a variable so two applies do not collide.
variable "smoke_id" {
description = "Unique id for this CI pass, so two applies do not collide."
type = string
}
locals {
tag = "pathly-tf-smoke"
}
resource "pathly_scenario" "api_health" {
name = "CI smoke API health ${var.smoke_id}"
url = "https://api.pathlyhq.com/health"
interval_sec = 86400
expect_text = "pathly-api"
expected_status = 200
severity = "critical"
tags = [local.tag, "ci"]
runbook = "https://pathlyhq.com/en/developers"
}
interval_sec = 86400 is one check a day. That is a long interval so a CI smoke does not poll all day, and it is the wrong interval for a checkout. A Ping does not spend the browser budget. That budget is Flow. A public home page usually wants 300 seconds. A health URL that only proves the process is up can wait longer. expected_status = 200 fails the check when the origin answers anything else. expect_text = "pathly-api" fails it when the status is 200 and the body is the wrong service. Both arguments belong together. Status alone is how a parking page stays green.
severity is critical, major or minor. It does not change the HTTP request. It changes how loud the failure is, and it is the predicate the SLA target below uses to decide which journeys get an objective. runbook is a URL shown with the alert. Put the page your on-call already trusts. Do not put a secret, a ticket id that contains a customer name, or a link that only works on a VPN you will have forgotten in a year.
The name includes smoke_id because Terraform names are labels for humans, not primary keys. Two smokes with the same name and different ids are two resources. The API id, not the name, is what terraform import uses later.
An availability target on that Ping
A Ping that fails is an incident. An SLA target is the budget you are willing to spend before the month is considered broken. The smoke attaches one target to the scenario it just created.
resource "pathly_sla_target" "api_health" {
scenario_id = pathly_scenario.api_health.id
name = "CI smoke SLA ${var.smoke_id}"
objective_pct = 99.0
window_days = 7
exclude_maintenance = true
warn_at_budget_ratio = 0.8
}
scenario_id is the id Terraform just received. Do not paste a UUID from the console into this block unless you are importing. A pasted id from another workspace is how a plan updates the wrong check.
objective_pct = 99.0 over window_days = 7 is a loose objective for a smoke. A payment path in production is usually tighter and longer, for example 99.9 over 30 days. The percentage is not a marketing claim about Pathly. It is the number you asked the product to measure on your own runs.
exclude_maintenance = true only helps if the maintenance window is also in this configuration. A window declared in the console, and a target that ignores it, spends the error budget on an outage you announced. The alert then punishes a planned job. warn_at_budget_ratio = 0.8 fires when eight tenths of that budget is already gone, while there is still time to stop shipping risky changes. Waiting for 100 percent means the warning and the breach arrive together.
On a real set of Pings, attach the target only to the critical ones. A marketing page that is allowed to be briefly red should not share the payment objective.
resource "pathly_sla_target" "objective" {
for_each = {
for key, j in var.journeys : key => j if j.severity == "critical"
}
scenario_id = pathly_scenario.journey[each.key].id
objective_pct = 99.9
window_days = 30
exclude_maintenance = true
warn_at_budget_ratio = 0.8
}
The for_each filter is ordinary Terraform. Rows whose severity is not critical get a Ping and no objective. That is a product decision expressed as data, which is the reason to leave the console.
A maintenance window that does not mute the whole organisation
The smoke window is scoped to one scenario. Sunday, starting at minute 180 of the day, for 30 minutes. Minute 180 is 03:00. Weekday 7 is Sunday in this schema.
resource "pathly_maintenance_window" "weekly" {
scenario_id = pathly_scenario.api_health.id
weekday = 7
start_minute = 180
duration_min = 30
reason = "CI smoke weekly ${var.smoke_id}"
}
scenario_id matters. Without it, a window can cover more than the check you meant to silence. The smoke sets the id so a CI pass does not mute every Ping in the organisation for half an hour. reason is the sentence a human reads on Monday. Write the job name, not "test".
A weekly backup that should not spend the error budget looks like this. It is the shape from the practice article, and it is not scoped in that example, so copy the scenario_id line from the smoke when the backup belongs to one journey.
resource "pathly_maintenance_window" "backup" {
weekday = 7
start_minute = 3 * 60
duration_min = 120
reason = "Weekly backup"
}
start_minute is minutes from midnight in the window's clock, not a cron expression. 3 * 60 is the same integer as 180. Prefer the multiplication when the hour is the thing you want a reviewer to see. duration_min = 120 is two hours. A duration that covers the whole night will also cover incidents you wanted to know about. Keep it as long as the job and no longer.
Failures inside the window do not spend the error budget when the target has exclude_maintenance = true. They can still be visible in the run history. Maintenance is not a delete. When the window ends, the next failed run alerts again.
The webhook, and the URL that never comes back
resource "pathly_webhook" "alerts" {
url = "https://example.com/hooks/pathly-smoke/${var.smoke_id}"
events = ["run.failed", "run.recovered"]
}
Those two event names are the set the API accepts on this resource: a run failed, and a run recovered. There is no third event to subscribe to. If your receiver only handles failure, pass a one-element list. An empty list is not a useful webhook.
The URL is written on create and on update. A later read does not return it. The API returns a twelve-character fingerprint instead. The fingerprint changes when the address changes. It is not enough to reconstruct the address, and it is enough for the next plan to show that someone edited the destination in the console.
output "webhook_fingerprint" {
value = pathly_webhook.alerts.url_fingerprint
}
Treat a fingerprint change as drift. Do not try to reverse it. Put the real receiver URL in the configuration, in a private module or a private variable file, and let Terraform set it. The smoke uses example.com because that name is reserved for documentation. A real hook must be HTTPS on a host you operate.
Deliveries are signed. The signature header is X-Pathly-Signature and the value starts with sha256=. Verify that header with the webhook secret before you trust the body. Do not log the secret next to the payload. The secret stays in state on the machine that applied the configuration. That is another reason state belongs in an encrypted backend, not in git.
A Ping going red is run.failed. The same Ping going green again is run.recovered. Your receiver should close or resolve on the second event, otherwise the incident stays open after the site is back.
An estate as data
Past three or four checks, one block per resource becomes a list you re-read on every addition. A variable plus for_each makes a new journey one row.
resource "pathly_scenario" "journey" {
for_each = var.journeys
name = each.value.name
url = each.value.url
interval_sec = each.value.interval_sec
expect_text = each.value.expect_text
max_latency_ms = each.value.max_latency_ms
expected_status = 200
severity = each.value.severity
tags = concat(["terraform", var.environment], each.value.tags)
}
max_latency_ms is per journey because a cached home page and a payment page do not share a budget. A threshold copied from the home page will page you for a checkout that was always slower, and you will mute it. expected_status = 200 is fixed in the loop on purpose. If one URL is allowed to answer 204, give it its own resource instead of a nullable field half the team will forget.
concat puts a common tag, the environment, and the row's own tags on every check. The common tag is what the data source filters on. If each row invents its own tag and nothing is shared, the drift check in the next section cannot see them as one set.
Keep URLs, names and thresholds in the variable. Keep the rules in the resource. A reviewer then diffs a row, not a copy of the whole block.
Spotting what was created by hand
The useful data source is the one that lists scenarios the API can see, then subtracts the ids this configuration owns.
data "pathly_scenarios" "prod" {
filter_tag = var.environment
depends_on = [pathly_scenario.journey]
}
output "scenarios_outside_terraform" {
value = [
for s in data.pathly_scenarios.prod.scenarios : s.name
if !contains([for j in pathly_scenario.journey : j.id], s.id)
]
}
filter_tag limits the list to one environment. Without it you compare production code to every sandbox check in the org and the output is noise. depends_on matters on the first apply, because the data source can be read before the resource it should see has been created. After that, Terraform usually orders them by the reference in the output. Leave the dependency in. A green first apply is the one people trust.
A name in that output is a Ping somebody created in the console. Nobody reviews it in pull requests, and nobody deletes it when the environment is destroyed. Import it, or delete it on purpose. Do not ignore the list until it is long.
The smoke checks the same idea with four booleans. Each created id must appear in the matching data source: scenarios filtered by tag, all SLA targets, maintenance windows filtered by scenario id, and all webhooks. pathly_sla reads the measured budget for that scenario. On the first run the measure can be empty. The smoke only requires the read to succeed.
Org-wide reads are absent from that smoke on purpose. The CI key is scoped to the resources it manages. It does not have org:read. A data source that calls a route the key cannot read fails the apply. That failure is the scope working.
Import, then an empty plan
You do not rebuild monitoring to move it into code. Each resource imports by the id you already have. For a scenario, that id is the one in the console address.
terraform import pathly_scenario.checkout mon_01H8ZK
Replace mon_01H8ZK with the real id. After the import, terraform plan shows the gap between the live object and the file. While the plan is not empty, apply changes the existing scenario in place. It does not create a second one beside it. A recreate would drop history and incidents, which is the record you were keeping.
The same import exists for the SLA target, the maintenance window and the webhook. Import the scenario first, because the others store its id.
The usability test is one sentence. After a successful apply, the next plan is empty. A provider that proposes a change on every pass teaches people to stop reading plans. The day the plan is true, nobody sees it. In-place update of the URL, the interval and the latency threshold is what makes that empty plan possible. Those fields are readable back as the same values you wrote. The webhook URL is the exception, and it is represented by the fingerprint so a real change still shows up.
If a plan wants to replace a scenario after you only changed interval_sec, stop. Do not apply a replacement to accept the diff. That is the bug the empty-plan rule is there to catch.
What this provider will never declare
Four actions stay out of a key, and therefore out of HCL.
An API key cannot issue or revoke API keys. A key that could do that would cancel the scopes of every other key. A configuration file must not be able to widen itself.
An API key cannot invite a member. The new account would then issue whatever keys it wants. That is the same escalation one step later.
An API key cannot change plan or touch billing. A continuous integration token must not start a recurring charge.
An API key cannot export the organisation. The export contains members, which is personal data, and it does not belong in a plan log.
The provider follows those refusals. You will not find a pathly_api_key or a pathly_member resource. If a module offers one, it is not this provider.
The token you export as PATHLY_API_TOKEN still needs the scopes of the resources you declare. Writing scenarios needs scenarios:write, which includes read. An SLA target needs sla:write. A maintenance window needs maintenance:write. A webhook needs alerting:write. Triggering a run is a different scope, runs:trigger, and this file does not trigger runs. Do not add it because it was on a checklist.
Flow and Chain stay out of this file
A Ping answers "did this URL respond the way the file says." A Flow is a real Chrome session: click, type, assert a text that only exists once a person is signed in. A Chain is several HTTP calls in order, where one hop can extract a token for the next. Do not invent a pathly_flow resource. Flow is steps on pathly_scenario, with type = "browser". Chain is http_chain on the same resource. This file only declares the Ping.
Changing a Ping's address in this file updates that Ping. It does not migrate a Flow's recorded steps. Those steps are not returned on read, because a fill or an http_auth step can contain a password. State can still hold them, marked sensitive, and scenario_fingerprint shows a console edit. This file stays on the Ping fields so the first example has nothing write-only in it.
Apply order on a new workspace
terraform init downloads pathlyhq/pathly at the 0.1 line you pinned. terraform plan should show four adds the first time: scenario, SLA target, maintenance window, webhook. terraform apply creates them in dependency order because the target, the window and any output reference the scenario id. terraform plan a second time should show no changes. Then read scenarios_outside_terraform. An empty list means the tag you filtered on has no console-only checks. A non-empty list is the work queue.
Store state in a backend you already encrypt. Do not commit terraform.tfstate. The webhook secret and the fingerprint live there. The API key should not, as long as it came from PATHLY_API_TOKEN and not from a variable.
When you rotate the key, export the new value and re-run the plan. An empty plan means the rotation did not touch the resources. A 401 means the new key is missing a scope the old one had. Add the scope. Do not fall back to a key that can also invite members, because no such key exists, and a personal session token does not belong in CI either.
This is the whole loop. One provider block, one Ping that checks text, one objective that excludes maintenance you also declared, one window, one webhook whose URL you keep in the file because the API will not remind you of it, and a data source that lists everything else.
The outputs the smoke keeps
The smoke publishes the four ids it created, the webhook fingerprint, and one boolean that is true only when every created id was read back.
output "created_ids" {
value = {
scenario = pathly_scenario.api_health.id
sla_target = pathly_sla_target.api_health.id
maintenance_window = pathly_maintenance_window.weekly.id
webhook = pathly_webhook.alerts.id
}
}
output "all_resources_visible" {
value = (
local.scenario_visible &&
local.sla_visible &&
local.window_visible &&
local.webhook_visible &&
local.sla_measure_ok
)
}
all_resources_visible is the assertion a scheduled job can fail on. A create that returns 200 and a later list that omits the id is a broken read, and an empty plan will not catch it, because the plan compares state to config, not state to a filtered list. The data source is what compares state to the API. Wire that boolean into the job. Do not only archive the ids.
visible_in_inventory remains in the smoke as the older scenario-only check, so a job that already reads that output does not break. New jobs should read all_resources_visible.
When you copy the smoke into a real workspace, delete smoke_id from the names, set interval_sec to the interval you mean, replace the health URL, and keep the read-back outputs. The destroy at the end of CI is correct for a smoke and wrong for production. Remove the destroy step before the first real apply.
Top comments (0)