DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

Self-Hosted and SaaS Uptime Monitoring for Small Go Businesses Explained

A page says the nightly customer-summary job is late. The on-call view shows 14 overdue runs, a healthy /healthz endpoint, and no way to tell which tenant or AI agent step consumed the time. At that point, the uptime monitor has done its job too late.

TL;DR: For a small B2B SaaS app in an EU region, use an external managed probe when the team needs low operational overhead, and run the monitor on infrastructure you control when data location, network reach, or evidence ownership outweighs that overhead. Do not make either choice from the health endpoint alone. Combine an outside-in HTTP probe, a per-run cron heartbeat, and metrics for agent-loop latency and attributed cost. Keep alert evaluation outside the failure domain it observes.

The smallest useful design is two independent signals: one asks whether customers can reach the service, while the other proves that scheduled work completed before its deadline. Add cost and latency telemetry to the second path because "up" does not mean "economically healthy." This separation also makes the managed-versus-on-premise decision concrete: compare who operates the evidence path, where its event data travels, and what happens when your primary region is unavailable.

What should the first page tell the on-call?

The page should identify a violated promise, not merely a failed request. For this system, that promise might be: each active tenant's nightly agent run finishes within its allowed window, and the public API remains reachable from outside the production network. The page needs the affected scope, the last successful completion time, the deadline, and a link or identifier that joins the alert to traces and metrics. Cost belongs in the diagnostic view, even when it is not the paging condition.

After being paged by missed jobs and duplicate deliveries, I treat a process-level "healthy" response as weak evidence. It proves that one request reached one process and received an acceptable response. It does not prove the scheduler emitted a job, a worker claimed it once, the agent loop terminated, or the completion heartbeat arrived. Those are different failure boundaries.

Start with three signal classes:

Signal Question it answers Useful dimensions Typical response
External HTTP probe Can a user reach the service? region, endpoint, status class page on sustained loss
Cron completion heartbeat Did the scheduled unit finish on time? job, tenant cohort, schedule page after the grace window
Agent-loop metrics Where did time and spend go? tenant, workflow, step, model class investigate or enforce a budget

Keep tenant identifiers pseudonymous in the monitoring stream. Do not put prompts, generated text, access tokens, or customer names into metric labels. OpenTelemetry describes metrics as runtime measurements and notes that attributes provide dimensions; those dimensions are useful for aggregation, but an unbounded identifier such as a run ID creates a growing set of time series. Put the run ID in logs or traces and attach only bounded cohorts to metrics.

One signal should have fired earlier than the final page: the missing completion heartbeat. If the job has a 02:00 scheduled start, a 20-minute expected duration, and a 10-minute grace period, an overdue evaluation at 02:30 can catch the broken pipeline before customers arrive. Those numbers are an example policy, not a universal default. Derive them from the actual schedule, observed duration distribution, retry policy, and business deadline.

Should a small business use self-hosted or SaaS uptime monitoring?

"Managed or self-hosted?" sounds like a deployment question. Operationally, it is a custody question. Who retains the raw checks, who can change alert rules, who is awake when the monitor fails, and can the evidence path still report while the application region is impaired?

A managed service usually removes patching, storage maintenance, and alert-delivery plumbing from the application team's queue. It can also provide an observation point outside the production account. In exchange, probe results and heartbeat metadata cross into another processor's systems, retention and export depend on a contract, and private endpoints may require an additional network path. EU deployment does not by itself settle data-protection obligations; document the data fields, processing roles, transfers, retention, and deletion behavior with the people responsible for compliance. This option is not a fit when policy requires the complete event path to stay on controlled infrastructure or when the receiver must observe a private network without an approved ingress or egress path.

An on-premise monitor gives the operator direct control over storage, network placement, retention, and upgrades. That control has a bill. Count database care, backups, security updates, certificate renewal, alert delivery, and the engineer time needed to test restores. For cost attribution, charge that shared control-plane work to the monitoring service rather than pretending its compute invoice is the whole cost. Its limitation is independence: unless the team deliberately separates accounts, regions, credentials, DNS, and alert delivery, the monitor can share the outage it was meant to report. It is not a fit for a small team that cannot assign an owner and test recovery.

There is no maintenance-free choice.

Failure-domain independence is the sharpest test. A monitor installed in the same cluster, account, DNS zone, and region as the target can disappear with it. A second region or separate account improves independence, but now the team owns another production system. Conversely, an external service can see public reachability while lacking enough context to diagnose a private queue. Many small teams land on a mixed boundary: an external probe for public uptime and a controlled telemetry pipeline for job and cost details.

No topology removes the need for a dead-man signal. A failed job can emit an error; a scheduler that never started emits nothing. The monitor must know the expected cadence and declare the absence overdue.

Silence is a state.

Instrument the completion path in Go

The completion event should be emitted only after the durable business outcome commits. Sending it when a worker starts turns a queue delivery signal into a false success. If the work can retry, use an idempotency key at the business boundary so a second delivery does not produce a second customer summary.

This minimal Go example records bounded metrics and sends a completion heartbeat after CommitSummary succeeds. The endpoint and token are configuration, so the same contract works with a managed receiver or one operated by your team.

package jobs

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

type Metrics interface {
    Observe(ctx context.Context, name string, value float64, attributes map[string]string)
}

type RunResult struct {
    Job          string
    TenantCohort string
    Workflow     string
    Duration     time.Duration
    InputUnits   int64
    OutputUnits  int64
}

type Heartbeat struct {
    URL   string
    Token string
    HTTP  *http.Client
}

func RecordCompletion(ctx context.Context, metrics Metrics, hb Heartbeat, r RunResult) error {
    attrs := map[string]string{
        "job":           r.Job,
        "tenant_cohort": r.TenantCohort,
        "workflow":      r.Workflow,
        "outcome":       "success",
    }
    metrics.Observe(ctx, "agent.run.duration", r.Duration.Seconds(), attrs)
    metrics.Observe(ctx, "agent.input.units", float64(r.InputUnits), attrs)
    metrics.Observe(ctx, "agent.output.units", float64(r.OutputUnits), attrs)

    body, err := json.Marshal(map[string]string{
        "job":     r.Job,
        "outcome": "success",
    })
    if err != nil {
        return fmt.Errorf("encode heartbeat: %w", err)
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodPost, hb.URL, bytes.NewReader(body))
    if err != nil {
        return fmt.Errorf("build heartbeat request: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+hb.Token)
    req.Header.Set("Content-Type", "application/json")

    resp, err := hb.HTTP.Do(req)
    if err != nil {
        return fmt.Errorf("send heartbeat: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("heartbeat returned status %d", resp.StatusCode)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This code deliberately does not calculate currency from a model name. Unit rates change, and retries, cached work, tool calls, and shared infrastructure complicate the total. Record stable usage quantities at the step boundary, then apply a versioned rate table in a controlled aggregation job. Store the rate-table version with the result. That lets finance reproduce a tenant allocation without rewriting historical telemetry when a contract changes.

There is another idempotency edge: the business transaction can commit and the heartbeat can fail. Do not rerun the customer-visible action merely to make monitoring green. Consider the exact sequence. The worker stores the summary, commits at 02:17, and then loses egress before sending its heartbeat. At 02:30, a monitor that knows only about the missing heartbeat reports an overdue job. A runbook that says "retry the job" can now send the same summary twice. Persist an outbox record in the same transaction as the summary, deliver the heartbeat from that outbox, and deduplicate by the job's stable execution key. The diagnostic path should show the durable commit separately from notification delivery, so the on-call can replay evidence without replaying the business action. This is the kind of narrow distinction that looks fussy during design review and becomes the whole incident during a page. The page can distinguish "work incomplete" from "completion evidence delayed" only if those states are modeled separately.

Test the ugly paths. Stop the scheduler before enqueue. Kill a worker after commit but before heartbeat delivery. Return a slow dependency response, exhaust the retry budget, and deny egress to the monitoring receiver. Each test should produce one known state transition and one runbook action. If it produces two pages, fix routing before production does the experiment for you.

Make cost attribution useful without paging on invoices

For an AI agent loop, total request latency hides the expensive branch. Measure each bounded step: model request, tool execution, queue wait, retry backoff, and final commit. Record input and output usage quantities when the provider returns them, and mark missing usage as unknown instead of zero. Zero means measured absence; unknown means the accounting path failed.

Use a small label vocabulary such as tenant cohort, workflow, environment, outcome, and model class. Aggregate actual tenant allocation in a batch or streaming accounting layer keyed by a controlled tenant identifier. This keeps the metric system operable while preserving the detail needed for internal chargeback.

The paging rule should remain tied to user impact or an imminent service objective breach. Cost anomalies usually belong in a ticket or budget workflow because a single expensive run may be valid. A sustained increase in cost per successful workflow is more actionable than total spend: it normalizes for traffic and exposes retry amplification. Pair it with success count and latency, or a falling workload can make the ratio misleading.

The decision rule is simple: choose the boundary that can preserve independent evidence and satisfy data-handling constraints with an operational burden the team will actually fund. Then account for the monitor itself as a shared service. A nominally inexpensive deployment that nobody patches is not a cheaper control.

Tune the alert without training people to ignore it

Begin with the business deadline and work backward. If summaries must be available at 06:00, reserve time for a retry and human intervention before that deadline. The cron overdue threshold should sit before the last recoverable moment, not at an arbitrary multiple of the current median duration. For HTTP uptime, require enough consecutive evidence to screen a transient network error while still meeting the response objective.

Evaluate from at least one location that does not share the application's main failure domain. Route warnings and pages differently. A delayed low-priority tenant cohort may create a ticket; widespread deadline risk should page. Put ownership and the first diagnostic query in the alert, and ensure the runbook says when not to retry.

Bad thresholds have a real cost. Too tight, and ordinary duration variance pages the on-call, encourages blind retries, and can duplicate delivery. Too loose, and the first trustworthy signal arrives after the recovery window has closed. Review the alert after schedule changes, workload shifts, and dependency migrations. Also inspect pages that did not lead to action; they are evidence that the policy, grouping, or routing needs work.

The final architecture can stay small: one external reachability check, one deadline-aware completion signal per scheduled workflow, bounded OpenTelemetry metrics, and durable cost records joined outside the paging path. Operate those pieces on either side of the managed boundary according to evidence custody and failure independence. The labels matter less than the failure test.

Further reading

Top comments (0)