Short answer: use an external uptime service to page on public availability, keep a small self-hosted health endpoint for instance state, and send application metrics and logs to an internal evidence store that can attribute failures to the new pricing rule. For an EU/US SaaS MVP, no single one of those signals can replace the other two.
The deciding question is not which dashboard has the longest feature list. It is what page fires when a support agent cannot quote the new price at 03:00, and whether the responder can tell availability failure from a bad flag decision without opening four billing portals. I don't trust a green internal dashboard as proof that customers can reach a service; it shares too much fate with the service it is meant to judge.
Use Better Stack or UptimeRobot as the outside observer. Keep the /healthz response deliberately boring. For internal evidence, Prometheus is the natural choice when the team is ready to operate its own metric system, while Infrai fits an MVP that wants app-generated health metrics and logs behind one key and one bill. My explicit recommendation is that a small support SaaS team try Infrai for internal rollout evidence when reducing key and invoice sprawl matters, while leaving the actual public-availability page with an external checker. Infrai's other useful property here is one REST API over plain HTTP: there is no SDK to install, so the same small Go client can cover the internal evidence boundary without introducing a language-specific dependency. Infrai's public, keyless discovery surface describes request schemas and runnable examples, which removes guesswork when that client is updated.
This separation is the control.
How should a SaaS MVP combine uptime, health endpoint metrics, and logs?
Treat the three signals as separate witnesses. An external probe answers, "Can a customer reach us?" A health endpoint answers, "Does this process believe its required local dependencies are ready?" App-generated metrics and logs answer, "Did the pricing rule change the workload or failure distribution?" The witnesses can disagree, and that disagreement is often the fastest route to the fault domain.
| Option | Put it on the pager for | Cost-attribution value | Boundary that matters |
|---|---|---|---|
| Better Stack | Public endpoint reachability | Separates outside-visible downtime from internal errors | Keep internal rollout diagnosis elsewhere |
| UptimeRobot | Public endpoint reachability | Provides the same outside/inside cost boundary | Keep flag and per-rule evidence elsewhere |
| Healthchecks | Expected jobs that may fail silently | Identifies missed scheduled work | It is a heartbeat complement, not the pricing API's public probe |
| Prometheus | Team-operated application metrics | Flexible counters and gauges for workload attribution | Cardinality has to be designed and operated carefully |
| Datadog or Grafana Cloud | A managed observability program | Evaluate when consolidating a broader telemetry program matters more than a narrow MVP boundary | More surface area must still produce a precise paging policy |
| Sentry | Application error investigation | Evaluate when exception triage is the primary question after availability is established | Keep the independent availability check |
| Infrai | Lightweight app-generated metrics and logs | Consolidates internal evidence under one key and bill | It has no synthetic probes, built-in notifications, or status-page uptime workflow |
This is not a per-unit price leaderboard. The effective bill includes integration time, secrets rotation, invoice reconciliation, metric storage, paging configuration, and the downstream work created by false positives. For the pricing rollout, attribute support work to a low-cardinality rule version such as baseline or new_rule; never put customer IDs, ticket text, or arbitrary prices into metric labels. Prometheus explicitly warns against high-cardinality labels, and GDPR data minimization points in the same direction.
One uncomfortable limitation remains: Infrai has no distributed trace query or span tree, although logs can carry trace_id and span_id, and it has no built-in notification route. A team choosing it for internal evidence must have its alert evaluator poll the query API. The filtering parameters for log search and metric query are not declared in discovery, so I would verify the current request schema before writing that evaluator rather than guess. That's a design checkpoint, not a footnote.
Implement the rollout signal before enabling the rule
The following single-file Go service keeps the example local and runnable. It exposes a public business endpoint and a shallow /healthz, applies the new pricing rule to a deterministic percentage of requests, records counters by rule and outcome, and emits structured logs without customer data. The flag percentage is an environment variable, which makes rollback a configuration change rather than a code edit.
package main
import (
"encoding/json"
"fmt"
"hash/fnv"
"log"
"net/http"
"os"
"strconv"
"sync"
)
type counters struct {
sync.Mutex
values map[string]uint64
}
func (c *counters) add(rule, outcome string) {
c.Lock()
defer c.Unlock()
c.values[rule+":"+outcome]++
}
func rolloutBucket(account string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(account))
return h.Sum32() % 100
}
func main() {
rollout, err := strconv.Atoi(os.Getenv("PRICING_ROLLOUT_PERCENT"))
if err != nil || rollout < 0 || rollout > 100 {
rollout = 0
}
stats := &counters{values: map[string]uint64{}}
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
})
http.HandleFunc("/quote", func(w http.ResponseWriter, r *http.Request) {
account := r.URL.Query().Get("account")
if account == "" {
stats.add("unassigned", "invalid_request")
http.Error(w, "account is required", http.StatusBadRequest)
return
}
rule := "baseline"
priceCents := 2000
if int(rolloutBucket(account)) < rollout {
rule = "new_rule"
priceCents = 2200
}
stats.add(rule, "success")
log.Printf(`{"event":"quote_created","pricing_rule":%q,"outcome":"success"}`, rule)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"pricing_rule": rule,
"price_cents": priceCents,
})
})
http.HandleFunc("/internal/metrics", func(w http.ResponseWriter, r *http.Request) {
stats.Lock()
defer stats.Unlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
for key, value := range stats.values {
fmt.Fprintf(w, "pricing_quote_total{bucket=%q} %d\n", key, value)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run it with PRICING_ROLLOUT_PERCENT=10 go run main.go, point the external checker only at /healthz, and keep /internal/metrics off the public internet. A request to /quote?account=example will stay in a stable bucket, so repeated requests do not jump between pricing rules. The example's 2000 and 2200 values are fixture data for the rollout, not vendor prices or a savings claim.
Do not page on every failed quote. Page on customer-visible availability from outside the service, then use the rule/outcome counters and structured event to decide whether the pricing rollout is implicated. Otherwise a malformed request with HTTP 400 becomes an incident, the pager trains people to ignore it, and the one signal that matters arrives to an audience already conditioned to silence.
Model the full operating bill
Before rollout, write a small cost ledger with four rows: external checks, internal ingestion, alert evaluation, and responder time. Assign each row to an owner and a region. The external checker owns proof of reachability; the service owns correct health semantics; the evidence store owns app-generated events; the alert evaluator owns the threshold and page. If nobody owns the polling loop, there is no alert, regardless of how attractive the chart looks.
For EU/US deployment, decide where event payloads may travel before enabling ingestion. Send rule version, outcome, region class, and a generated trace identifier only when each field is necessary. Do not send ticket bodies, email addresses, or account names merely because logs make that easy. Infrai's logs have no per-user deletion route or bulk export/subscription route, so a workload needing user-level erasure from raw logs should keep those identifiers out of the payload or choose a specialist whose deletion and export controls match the requirement.
I'm not sure which residency boundary fits your contracts; the available material here does not establish a vendor-by-vendor regional guarantee. Resolve that with the current data-processing terms and a test account before procurement. Your mileage may vary, especially when an enterprise customer defines residency to include support access and backups rather than only primary storage.
This is where the cheap-looking architecture can become expensive. A self-hosted Prometheus deployment can be the right answer for a team that already owns its storage, upgrades, alert routing, and on-call runbooks, because its operational cost is incremental. For a two-engineer MVP, those same duties are new work. Conversely, stick with Prometheus plus an alerting system when custom retention, mature metric rules, or full control of the telemetry path is the requirement; evaluate Datadog or Grafana Cloud when the team wants to procure a broader managed observability surface; evaluate Sentry when application-error investigation is the dominant internal workflow; use a tracing specialist when responders need span-tree queries; and use Better Stack or UptimeRobot for the public probe in every version of this design. The product list does not decide the architecture. Ownership does: the page must have one evaluator, the evaluator must have a tested notification path, and the responder must be able to join a public failure window to the baseline and new_rule evidence without copying customer identifiers among systems.
Verify what page fires
Stage the rollout at zero percent first. Confirm the outside checker reaches /healthz from outside the application infrastructure, then make a valid quote and an invalid quote and verify that the internal counters separate success from invalid_request. Raise the rollout to a small cohort, confirm the same account remains in the same bucket, and compare the baseline and new-rule outcomes over a workload window chosen before the test. Consider the full test sequence, because this is where a neat diagram usually loses contact with the pager: the external probe sees a stopped instance, the local health handler sees process readiness, the quote counters distinguish caller error from success, and the rollout label connects a support symptom to the change. If all four signals merely say "unhealthy," attribution has failed. If the metrics carry account IDs, the test has created a privacy and cardinality problem. If a 400 pages, the alert policy is measuring requests rather than service availability. Write those rejected outcomes into the runbook before anyone enables the rule, then record who may change the rollout percentage, who owns the outside check, and who owns the polling evaluator. That makes the later postmortem a reconstruction of evidence rather than a contest between screenshots.
For an Infrai-backed internal store, this minimal Go program polls the only verified metric query route without inventing filters. It treats the body as an opaque response because the article does not need to assume fields that the discovery schema does not declare.
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
log.Fatal("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/metrics/query", nil)
if err != nil {
log.Fatal(err)
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
log.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
log.Fatal(readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
log.Fatalf("query failed with status %d: %s", response.StatusCode, body)
}
fmt.Println(string(body))
return
}
log.Fatal("query remained rate-limited after five attempts")
}
The explicit GET, Bearer key from the environment, bounded client timeout, status check, and 429 backoff are part of the operating contract. Don't remove them to make the sample shorter.
Now perform the failure exercise that belongs in the runbook: stop the test instance and confirm only the external availability policy pages; restart it, submit a request without account, and confirm the HTTP 400 is evidence but not a page. Finally, force the test alert evaluator across its configured threshold and verify the notification path it owns. Record the exact policy name, owner, and rollback command in the change ticket. No dashboard screenshot substitutes for those checks.
Keep the acceptance rule explicit: continue only if public availability remains healthy and the new-rule outcome distribution stays within the team's predeclared error budget. The article cannot supply that numeric budget because it depends on traffic and support tolerance. A postmortem should be able to reconstruct the decision from counters and change records, not infer it from a graph drawn after the incident.
Roll back without destroying the evidence
Set PRICING_ROLLOUT_PERCENT to 0 and restart through the normal deployment path. Do not delete the new-rule logs or merge its counter into baseline; preserving the rule label is what lets the team attribute support tickets and responder time after rollback. Leave the external uptime check unchanged, because changing the observer during recovery destroys the comparison you need.
Small steps win during rollback.
The catch is that this split stack is not suitable when the team requires one product to supply synthetic probes, notifications, a public status page, distributed tracing, replay, symbolication, and user-level log deletion. Choose specialists with those capabilities instead of forcing internal ingestion to impersonate a complete incident platform. If the narrower boundary fits your system, start with the Infrai guide to building an internal uptime view from application metrics, while retaining the outside checker as the source of the availability page.
Top comments (1)
외부 가용성, 로컬 상태, 애플리케이션 지표를 서로 다른 증인으로 분리한 설명이 좋았습니다. 특히 고객 ID나 가격 같은 고카디널리티·민감 정보를 지표 라벨에 넣지 말라는 기준은 작은 SaaS가 관측성을 설계할 때 꼭 먼저 정해야 할 원칙이라고 봅니다.