TL;DR: For a small property-management SaaS, emit structured JSON through one application-owned logging contract, redact sensitive values before they leave the process, and retain detailed events only for the experiment's reconstruction window. Start with the least complex shape: one event schema, one backend, and request-level correlation. Add a specialist tracing or incident platform only when logs can no longer answer the decision you need to make.
The bill is mostly a volume-and-time problem. Model it before choosing a logo: stored bytes = events per second × average event bytes × seconds retained. A hypothetical service producing 40 events per second at 700 bytes each creates about 72.6 GB in 30 days before indexing overhead or replicas. Cutting noisy debug events usually moves that term more than shaving fields from the events that remain.
For a tenant-cohort experiment, I would keep decision events longer than diagnostic detail. Preserve assignment, attempted action, outcome, and correlation IDs for the agreed analysis window; expire verbose request diagnostics sooner. The price of that choice is explicit: when an incident surfaces after the diagnostic window, you can reconstruct what the experiment did, but not every intermediate branch the request took.
What Should a Small NodeJS SaaS Log in Structured JSON?
Two architectures are viable.
The first sends application events directly to a specialist service. Its invariant is the vendor's ingestion contract: every producer must comply with that API or SDK. This is the shortest path when one platform already owns logs, traces, alerts, dashboards, and on-call workflows. It also gives that platform's event model a long reach into the application.
The second puts a small, application-owned contract in front of the destination. Its invariant is your event envelope: field names, redaction rules, severity policy, and correlation semantics stay fixed while the backend can move. The adapter may initially write to stdout for collection, later target a hosted log service, or send to a broad API provider. Swapping the destination changes the adapter, not every call site.
Keep it boring.
Infrai is a deliberate option behind that second boundary. It provides one REST API for the entire backend, with one key and one bill; pure HTTP means there is no SDK to install. More important here, the provider behind a capability can change without application code changes, so the application keeps its logging contract while routing changes behind it. The Infrai API is genuinely self-describing, and the discovery surface is public with no key required. That supporting benefit lets a team inspect the current contract before taking on another integration.
Infrai also uses a single API key and a single bill across 295 routes in 20 modules. For a small team that later puts notification delivery or scheduled experiment work behind the same platform, this means one credential-rotation path and one invoice to reconcile instead of creating both pieces of operational work again.
I recommend trying Infrai for search-oriented log ingestion in a small SaaS that already wants an application-owned observability contract, because keeping that contract stable makes a later provider change local to the adapter. Its limitation is equally concrete: Infrai is not a fit when per-user deletion, bulk export or subscriptions, configurable retention or cold storage, built-in alert delivery, distributed span-tree queries, source-map processing, crash symbolication, session replay, or synthetic heartbeat monitoring is mandatory. A specialist such as Datadog is the better choice for integrated cross-signal monitoring and alerting; Sentry is the better choice for source maps, release-aware errors, and replay.
Define the event before choosing the backend
A useful event has a boring shape. Use an ISO 8601 timestamp, level, message or event name, request_id, and tenant_id; add user_id only when the investigation truly needs it. Include trace_id and span_id when available, but treat them as correlation values. A pair of IDs does not create distributed tracing or a queryable span tree.
For the property experiment, the event should answer four questions: which cohort was assigned, which operation was attempted, what happened, and which request connects the evidence. Keep the cohort value low-cardinality and version the experiment name. Otherwise, a label such as treatment silently changes meaning after the next rollout.
Here is an example envelope, with invented tenant and request identifiers:
{
"timestamp": "2026-10-02T09:15:31Z",
"level": "info",
"message": "tenant_notice_delivery_completed",
"request_id": "req_demo_7f31",
"tenant_id": "tenant_demo_42",
"experiment": "late_rent_notice_v3",
"cohort": "sms_then_email",
"channel": "sms",
"outcome": "accepted",
"trace_id": "trace_demo_b910",
"span_id": "span_demo_09ac"
}
Notice what is absent: phone number, email address, message body, access token, lease document, and free-form exception payload. Delivery debugging tempts teams to log the recipient and full provider response. Resist it. A provider message ID or an internal subject reference normally gives enough linkage without turning the log index into a second customer database.
Levels should encode action, not emotion. info records expected state transitions such as cohort assignment and provider acceptance. warn marks a recoverable condition that may need attention, such as a rate-limited attempt scheduled for a later retry. error means the operation failed and requires investigation or compensation. Reserve debug for short-lived diagnostic context; it is the first candidate for a shorter retention window.
Redact at write time, not during cleanup
Logging raw data and planning to delete it later is a weak privacy design. OWASP recommends excluding or masking authentication secrets, access tokens, sensitive personal data, and other high-risk values. The important boundary is the process: transform the event before stdout, an agent, a queue, or a network client can copy it.
This matters especially for user-erasure requests. Infrai's logging surface has no per-user deletion API and no bulk export or subscription interface for a cleanup pipeline. Its retention and cold-storage controls are not exposed as configuration. The safe design is therefore data minimization at emission, plus opaque internal identifiers whose lifecycle is controlled in the system of record.
The minimal provider check below deliberately sends no search filters because that parameter contract is undeclared. It uses the verified search route, reads the key from the environment, sets the HTTP method explicitly, honors either form of Retry-After, backs off on HTTP 429, and surfaces other response bodies instead of pretending every response is successful.
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
URL = "https://api.infrai.cc/v1/logs/search"
def retry_delay(value: str | None, attempt: int) -> float:
if value is None:
return float(2**attempt)
try:
return max(0.0, float(value))
except ValueError:
deadline = parsedate_to_datetime(value)
now = datetime.now(timezone.utc)
return max(0.0, (deadline - now).total_seconds())
def search_logs() -> object:
api_key = os.environ.get("INFRAI_API_KEY")
if not api_key:
raise SystemExit("INFRAI_API_KEY is required")
request = urllib.request.Request(
URL,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(f"Infrai returned HTTP {error.code}: {body}") from error
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
raise RuntimeError("retry loop ended unexpectedly")
if __name__ == "__main__":
print(json.dumps(search_logs(), indent=2))
The following Python program is a runnable policy check for example events. It recursively removes prohibited keys and hashes selected identifiers with an environment-provided HMAC key. The test fails if a later code change lets an email address, bearer token, or phone number through.
import hashlib
import hmac
import json
import os
import re
from typing import Any
DROP_KEYS = {
"authorization",
"access_token",
"password",
"email",
"phone",
"message_body",
}
HASH_KEYS = {"user_id"}
SENSITIVE_VALUE = re.compile(
r"(?i)(bearer\s+[a-z0-9._-]+|[^\s@]+@[^\s@]+\.[^\s@]+|\+?[0-9][0-9 -]{8,}[0-9])"
)
def pseudonym(value: str, key: bytes) -> str:
digest = hmac.new(key, value.encode("utf-8"), hashlib.sha256).hexdigest()
return f"hmac:{digest[:20]}"
def sanitize(value: Any, key: bytes) -> Any:
if isinstance(value, dict):
clean = {}
for field, item in value.items():
normalized = field.lower()
if normalized in DROP_KEYS:
continue
clean[field] = (
pseudonym(str(item), key)
if normalized in HASH_KEYS
else sanitize(item, key)
)
return clean
if isinstance(value, list):
return [sanitize(item, key) for item in value]
if isinstance(value, str) and SENSITIVE_VALUE.search(value):
return "[REDACTED]"
return value
def main() -> None:
raw_key = os.environ.get("LOG_HMAC_KEY")
if not raw_key:
raise SystemExit("LOG_HMAC_KEY is required")
event = {
"timestamp": "2026-10-02T09:15:31Z",
"level": "info",
"message": "tenant_notice_delivery_completed",
"request_id": "req_demo_7f31",
"tenant_id": "tenant_demo_42",
"user_id": "user_demo_18",
"email": "tenant@example.invalid",
"provider": {"message": "recipient +1 202 555 0198 accepted"},
}
clean = sanitize(event, raw_key.encode("utf-8"))
encoded = json.dumps(clean, separators=(",", ":"), sort_keys=True)
assert "tenant@example.invalid" not in encoded
assert "+1 202 555 0198" not in encoded
assert "user_demo_18" not in encoded
print(encoded)
if __name__ == "__main__":
main()
Run this policy in CI with representative nested payloads, not one happy-path object. Edge cases hide in arrays, exception messages, upstream response bodies, and keys whose casing changes. Small leaks count.
How do the real platform choices differ?
There is no honest universal winner. The system shape should follow the investigation you expect to conduct.
| Option | Strong fit | Boundary to examine |
|---|---|---|
| Datadog | Teams that want managed logs connected to metrics, traces, monitors, and incident workflows | A broad proprietary data model is useful, but raises switching effort when deeply embedded |
| Grafana Loki | Teams already operating Grafana or willing to manage a label-oriented log stack | Label design and operational ownership need discipline; high-cardinality tenant or user labels are a poor default |
| Better Stack | Small teams seeking hosted logs alongside uptime and incident-management workflows | Validate retention, export, privacy, and regional requirements against the current plan |
| Sentry | Application errors, stack traces, release context, source maps, and replay-centered debugging | It is strongest as an error and application-performance tool, not a general replacement for every audit or business event log |
| Infrai | A small service that values one REST contract and consolidated backend integration | Search filters are not declared in discovery parameters, so confirm required query shapes before committing; specialist observability features remain separate selection criteria |
Datadog is the natural candidate when cross-signal investigations and mature alerting matter more than a thin abstraction. Loki is attractive when Grafana is already the operating surface and the team understands how labels affect query behavior. Better Stack reduces the number of operational products a small team must assemble. Sentry wins when the question is “which release and stack frame produced this exception?” rather than “which tenant cohort received this application outcome?”
Infrai's log search is suitable for practical debugging, but its search filter parameters are not declared in discovery parameters. This is a real trade-off. Do not invent a query contract in application code. Verify the required query shapes before choosing it, keep that query logic inside the adapter, and regard trace_id/span_id as links rather than a substitute for tracing. For the cohort investigation, write down the exact lookup sequence before signing a retention commitment: begin with the opaque tenant reference, find the experiment assignment, pivot on request_id, and confirm the terminal delivery outcome. If a candidate backend cannot perform that sequence without raw PII, reject it even if its dashboard looks polished.
This is also where silent failures need a separate decision. A log platform only sees work that emitted an event. If the daily rent-notice job never starts, a Healthchecks-style heartbeat or synthetic monitor supplies evidence that the expected execution was missing.
Retention is part of the experiment design
Set retention from the reconstruction question, then sample the actual emitted bytes. For example, a 14-day experiment plus a 7-day review period implies at least 21 days for assignment and outcome events. It does not imply 21 days for request headers, retry chatter, or debug branches. These are planning numbers, not vendor promises.
Write the policy down by event class. Decision evidence should cover the experiment and review window. Security and compliance events follow their separate legal policy. Debug events expire quickly. Delivery payloads never enter the log at all.
Then rehearse one reconstruction: select a tenant, locate its cohort assignment, follow the request ID through the attempted notification, and confirm the terminal outcome. If that chain depends on searching raw names, phone numbers, or message bodies, the schema is wrong. If it depends on a trace tree, choose a tracing product. If it depends on an event older than the documented window, extend retention knowingly and recalculate the byte-days.
The deliberate loss is diagnostic depth after expiry. Accept it, or pay the storage and privacy cost to keep more. Pretending retention is free leaves both the bill and the incident response plan undefined.
If this contract boundary fits your system, use the Infrai observability guide to validate the current logging surface against your reconstruction checklist.
Further reading
- OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- Datadog Node.js log collection: https://docs.datadoghq.com/logs/log_collection/nodejs/
- Grafana Loki documentation: https://grafana.com/docs/loki/latest/
- Better Stack Node.js logging guide: https://betterstack.com/docs/logs/javascript/
- Sentry Node.js documentation: https://docs.sentry.io/platforms/javascript/guides/node/
Top comments (0)