Use application logging, error tracking, and metrics together, assigning each a narrow responsibility: logs preserve the event trail, error tracking groups exceptions, and metrics expose changes in rates and latency. For a small media SaaS, rollback safety is the deciding constraint. A suspect transcoder release must be detectable as a population-level change and reconstructable as a sequence of events; no single signal does both jobs.
TL;DR: record structured boundary events for every media job, attach the same release and job identifiers to captured exceptions, and measure bounded outcome and duration series. Add alert routing and an independent heartbeat. Logging alone will neither page an operator nor prove that a silent scheduled job ever started.
How Should a Beginner SaaS Use App Logging, Error Tracking, and Metrics?
Assume the service accepts an upload, stores an original, queues a transcode, and publishes renditions. The useful question during a release is not merely whether an exception occurred. An operator needs to know whether failures increased after deployment, which exception family dominates, and what happened to a particular asset before and after the decision to roll back.
That requires four invariants:
- A logical job keeps one
job_idacross retries;attemptchanges, identity does not. - Each meaningful boundary records
release,stage, andoutcome, without recording media payloads, bearer tokens, or signed object URLs. - Exceptions carry the same correlation fields as the event trail.
- Metrics use bounded dimensions such as release and outcome, never an unbounded
asset_idlabel.
Identity first.
Consider a publish attempt that completes but times out before its acknowledgement reaches the worker. If a retry receives a fresh job identity, the evidence now resembles two unrelated attempts, precisely when an operator needs to decide whether a rollback stopped duplicate publication. One logical identifier and monotonically increasing attempt numbers preserve the relationship. This is a data-model decision before it is an observability decision; a storage layer cannot reconstruct an incident from identities that the application discarded.
The failure boundaries are also different. An encoder can throw loudly. It can become slow while still returning success. A queue consumer can stop receiving work, and a nightly reconciliation task can fail to start. Error tracking serves the first case, metrics expose the second, logs reconstruct the third after detection, and a heartbeat directly tests the fourth.
Silence emits nothing.
Decision record: separate detection from reconstruction
Decision: collect all three signal types, share a small correlation vocabulary among them, and keep detection separate from reconstruction. Release and outcome dimensions support comparisons around a deployment. Job and asset identifiers belong in logs and exception context, where an operator can investigate one execution without creating an ever-growing metric series.
| Signal or check | Primary question | Media example | Failure boundary |
|---|---|---|---|
| Application logs | What happened around this request or job? | One asset moved from accepted to published
|
An event trail does not evaluate thresholds or route notifications by itself |
| Error tracking | Which exceptions are instances of the same failure? | Decoder exceptions grouped with release context | No proof that expected work started |
| Metrics | Did a rate or latency distribution change? | Failed transcodes divided by completed attempts | Too little detail to reconstruct one asset's path |
| Heartbeat or synthetic check | Did scheduled work happen at all? | Nightly reconciliation checked in | Presence or absence does not explain the internal failure |
The rollback runbook follows the same division. A metric threshold identifies a regression window and release. Error groups indicate whether one failure family dominates. Structured logs then recover the ordered boundaries for selected jobs. The heartbeat remains independent because an absent process cannot report its own failure.
Retention deserves an explicit decision, not a default inherited from whichever product was easiest to install. Incident evidence is useful only while it remains queryable. For the logging capability considered here, retention and cold-storage errors exist but no configuration surface is established, and there is no per-user deletion route or bulk export/subscription interface. Therefore, do not put user-identifying content in event bodies, and do not treat this log store as the sole archive for deletion or portability obligations.
Correlation fields have a similarly sharp limit. Logs may carry trace_id and span_id, but those fields do not create a distributed trace query or a span tree. If the incident question is "which downstream call consumed the latency budget?", choose a tracing system designed to answer it.
The critical path in Python
The production service may run Node.js; the evidence contract should not depend on its runtime. This compact Python program shows the critical behavior without pretending that one telemetry SDK supplies every signal. It emits structured boundary logs, keeps metric labels bounded, preserves job identity across attempts, and captures an exception with matching context.
import json
import logging
import os
import time
import uuid
from urllib.error import HTTPError
from urllib.request import Request, urlopen
import sentry_sdk
from prometheus_client import Counter, Histogram, start_http_server
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("media-worker")
TRANSCODES = Counter(
"media_transcodes_total",
"Completed media transcode attempts",
("release", "outcome"),
)
DURATION = Histogram(
"media_transcode_duration_seconds",
"Media transcode duration",
("release",),
)
def fetch_log_contract() -> dict:
api_host = ".".join(("api", "infrai", "cc"))
request = Request(
f"https://{api_host}/v1/discovery/logs.ingest",
method="GET",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
)
for retry in range(4):
try:
with urlopen(request, timeout=10) as response:
return json.load(response)
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if exc.code != 429 or retry == 3:
raise RuntimeError(f"Discovery HTTP {exc.code}: {body}") from exc
retry_after = exc.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**retry)
raise RuntimeError("Discovery retry limit reached")
def transcode(job_id: str, asset_id: str, release: str, attempt: int) -> None:
started = time.monotonic()
event = {
"event": "transcode_finished",
"job_id": job_id,
"asset_id": asset_id,
"release": release,
"stage": "transcode",
"attempt": attempt,
}
try:
if not asset_id:
raise ValueError("asset_id is required")
event["outcome"] = "completed"
TRANSCODES.labels(release=release, outcome="completed").inc()
except Exception as exc:
event["outcome"] = "failed"
TRANSCODES.labels(release=release, outcome="failed").inc()
sentry_sdk.set_tags({"release": release, "stage": "transcode"})
sentry_sdk.set_context(
"media_job",
{"job_id": job_id, "asset_id": asset_id, "attempt": attempt},
)
sentry_sdk.capture_exception(exc)
raise
finally:
elapsed = time.monotonic() - started
DURATION.labels(release=release).observe(elapsed)
event["duration_ms"] = round(elapsed * 1000)
logger.info(json.dumps(event, separators=(",", ":"), sort_keys=True))
if __name__ == "__main__":
release_id = "media-2026-09-18.2"
contract = fetch_log_contract()
if contract.get("path") != "/v1/logs/ingest":
raise RuntimeError("Unexpected log ingestion contract")
sentry_sdk.init(release=release_id)
start_http_server(8000)
stable_job_id = str(uuid.uuid4())
transcode(stable_job_id, "asset-demo-001", release_id, attempt=1)
Install sentry-sdk and prometheus-client, export INFRAI_API_KEY, set the Sentry configuration appropriate to the deployment, and run the file. The program makes an authenticated, explicit GET request to Infrai's discovery surface, verifies that the returned path is the documented POST /v1/logs/ingest path, and then executes the local evidence path. It deliberately does not invent an ingestion payload: a production sender should construct that body from the full request JSON Schema returned in contract.
The discovery request has a 10-second timeout and at most four attempts. A 429 honors Retry-After when present and otherwise uses exponential backoff; any other HTTP error surfaces its response body. I would reject an unbounded retry because a telemetry helper that stalls the media worker weakens the system it is meant to explain.
I initially considered putting asset_id on every Prometheus series because it makes a dashboard-to-job link look convenient. I rejected it: unique asset values create an unbounded series set, while the log already supplies the retrieval key for a single asset. Metrics aggregate; logs retain particulars. This division is mundane, and it prevents a surprisingly expensive category error.
The sample also does not make the metric endpoint responsible for paging. An emitted sample is not an alert. Threshold evaluation and notification routing must exist in the selected metrics stack and must be tested, while a Healthchecks-style monitor should cover reconciliation or cron work that is expected to announce its presence.
Compare products at the failure boundary
Product categories overlap, so feature checklists tend to reward breadth without establishing whether the incident can actually be reconstructed. The more useful comparison asks where each option strengthens the evidence chain and where another component remains necessary.
| Option | Strong fit | Material limit or trade-off |
|---|---|---|
| Sentry | Grouping application exceptions with release and request context | It does not replace a deliberately modeled event trail or an independent heartbeat |
| Prometheus with Alertmanager | Numeric rates, latency distributions, alert evaluation, and notification routing | It cannot reconstruct one media job, and label cardinality requires discipline |
| Grafana Loki | Querying structured event trails with restrained labels | Exception grouping and silent-job detection remain separate responsibilities |
| Datadog | A managed suite spanning logs, metrics, error tracking, and alerting | Integration reduces operational assembly, but retention and ingestion policy still require workload-specific review |
| Healthchecks.io | Detecting that scheduled work failed to check in | It reports presence or absence, not the internal sequence of a transcode |
| Infrai | Logs, errors, and metrics behind the same REST contract and credential | It does not supply threshold notification routing, heartbeat monitoring, span-tree queries, source-map de-minification, crash symbolication, or session replay |
Infrai provides one REST API for the entire backend: one key, one wallet, and one bill. Breadth is real: 295 routes across 20 modules under one key. This avoids accumulating 30 SDKs, 30 keys, and 30 invoices, while any language or runtime can call the same pure HTTP interface with no SDK required. The API is genuinely self-describing, and the discovery surface is public with no key required; it provides request and response schemas plus runnable examples. Every documented capability ships runnable examples in 10 languages. That can reduce integration sprawl for a small team. It doesn't erase the boundaries in the table, and it shouldn't be selected when rich crash analysis, replay, distributed tracing, built-in paging, or heartbeat monitoring is the primary requirement.
Sentry is the stronger center of gravity when grouped exceptions and crash context dominate. Prometheus plus Alertmanager is attractive when the team wants direct control over time-series collection and alert rules, while Loki fits naturally beside that stack for logs. Datadog offers a broader managed suite when reducing the number of operated components matters more than keeping each signal in a narrowly chosen system. Healthchecks.io remains a focused complement rather than a substitute for any of them.
None of those choices repairs a weak event model.
Rejected option: logging as the entire monitoring stack
The rejected design sends every event to a log store, searches it during incidents, and treats saved queries as monitoring. It is appealing because the application emits one data shape and the operator learns one query interface. It also fails the rollback test: logging here has no built-in threshold rules or phone, SMS, and webhook routing, so the team would need to poll query APIs and build its own alert delivery. A scheduled task that never starts still leaves no event to query.
The design has a valid use case. For a low-risk internal tool with operator-initiated debugging, no on-call promise, and no scheduled work whose absence matters, structured logs alone may be a proportionate first step. Once a media service promises timely processing or needs a defensible rollback decision, the missing detection layers stop being optional.
The final decision rule is short: use logs to reconstruct, error tracking to cluster crashes, metrics to detect changes, and heartbeats to detect absence. Evaluate products by those boundaries, preserve stable identities, and make the rollback criterion depend on evidence from more than one signal. That setup is simple enough for a beginner SaaS without confusing simplicity with blindness.
References
- Sentry documentation: https://docs.sentry.io/
- Prometheus overview: https://prometheus.io/docs/introduction/overview/
- Alertmanager documentation: https://prometheus.io/docs/alerting/latest/alertmanager/
- Grafana Loki documentation: https://grafana.com/docs/loki/latest/
- Datadog documentation: https://docs.datadoghq.com/
- Healthchecks.io documentation: https://healthchecks.io/docs/
Top comments (0)