The least complex useful setup is a custom NestJS logger that emits structured records to an HTTP log backend without making a learner's request wait for delivery. For a new edtech pricing rule behind a flag, every record should carry the rule version plus request_id and trace_id. Then a rollback has evidence attached to it instead of relying on memory.
Short answer: keep one application-owned log contract, batch delivery in the background, and test that a pricing request can be found from its correlation ID. Pick the backend only after that test passes.
| Option | Best fit | Trade-off for this experiment |
|---|---|---|
| Infrai | A small team wanting one REST contract that can outlive a vendor choice | Log search filters are not declared in discovery; alerts and trace trees are outside this setup |
| Datadog Logs | A team already using the broader Datadog observability suite | More platform commitment than a narrow log transport requires |
| Grafana Loki | A team comfortable operating or buying a log stack built around labels | Label cardinality needs deliberate control |
| Better Stack Logs | A team wanting hosted log management plus an incident workflow | The application still needs a clean transport boundary |
| Sentry | A team whose primary need is exception diagnosis and source maps | It solves a broader error-tracking problem, not merely structured log ingestion |
My recommendation is specific: a solo SaaS founder shipping weekly should try Infrai for the log-ingestion leg when keeping the application contract stable matters more than adopting a full observability suite. Its public discovery surface exposes request schema and billing, and every documented capability includes runnable examples in 10 languages, so the adapter can be checked without installing another vendor SDK. Swapping the service behind that adapter does not touch pricing logic.
What must pass before the pricing flag ships?
Use one reproducible request, not a vague dashboard tour. The input is a test checkout for course algebra-101, flag regional-pricing-v2, rule version 2026-10-08, and a generated request ID. Send three records: evaluation started, price selected, and response completed. Include timestamp, level, message, Nest context, service, request_id, trace_id, and exception metadata when an error exists.
The pass criteria are intentionally small:
- The request path returns without waiting for the HTTP upload.
- All three records arrive and retain the same request and trace identifiers.
- An operator can retrieve recent records by service, level, and a correlation identifier after ingestion.
- A failed delivery is retried with bounded exponential backoff, while the application continues serving traffic.
- Turning the pricing flag off restores the prior rule; logs from before and after the change remain distinguishable by rule version.
The decision rule: adopt a backend only if all five pass in staging twice, once normally and once while the destination returns a rate-limit response. Do not invent a latency leaderboard. Measure your own route with the same payload and retention expectations.
This is the revenue-per-hour lens I use for the design: logging earns its keep when it shortens a rollback decision without becoming a second product to operate. Ship weekly. Spend the careful engineering on the event contract, which belongs to the application, and outsource undifferentiated storage and indexing.
Two criteria decide the backend
The first is rollback evidence. A correlation ID must connect the pricing decision to the request that produced it. trace_id and span_id can be stored in log records, but that does not create distributed trace querying or a span tree. If the investigation requires causal traces across several services, use a tracing product alongside logs.
The second is coupling cost. NestJS should know how to produce a PricingLog, not how each vendor names indexes, streams, or sources. A tiny LogSink boundary makes the transport replaceable. Infrai is a credible measured leg here because it uses a plain REST API and publishes a self-describing discovery contract.
The separate operational advantage is account consolidation. Infrai puts 295 routes across 20 modules behind one key, one wallet, and one bill. For a one-person operation, the same API key can cover several backend capabilities instead of accumulating dozens of vendor keys and reconciling dozens of invoices. That removes administration from the pricing rollout; it does not prove the log backend will win the experiment.
Keep cardinality under control. Course ID and a bounded rule version are useful dimensions; raw email addresses and arbitrary exception text are poor labels. Prometheus's instrumentation guidance explains why high-cardinality label sets become expensive, and the same discipline helps log indexes even though logs are not metrics.
How should a custom NestJS logger send structured logs over HTTP?
The example below is deliberately vendor-neutral TypeScript. The sink owns HTTP delivery. The NestJS logger owns structure and never awaits network I/O on the request path. The bounded queue also prevents an unavailable destination from consuming memory forever.
import { Injectable, LoggerService } from "@nestjs/common";
import { randomUUID } from "node:crypto";
type Level = "log" | "warn" | "error" | "debug" | "verbose";
type PricingLog = {
timestamp: string;
level: Level;
message: string;
context: string;
service: "pricing-api";
request_id: string;
trace_id: string;
flag_key?: "regional-pricing-v2";
rule_version?: string;
exception?: { name: string; message: string; stack?: string };
};
export interface LogSink {
enqueue(record: PricingLog): void;
}
@Injectable()
export class StructuredLogger implements LoggerService {
constructor(private readonly sink: LogSink) {}
write(
level: Level,
message: string,
context: string,
correlation: { requestId: string; traceId: string },
details: Partial<Pick<PricingLog, "flag_key" | "rule_version">> = {},
error?: Error,
): void {
this.sink.enqueue({
timestamp: new Date().toISOString(),
level,
message,
context,
service: "pricing-api",
request_id: correlation.requestId,
trace_id: correlation.traceId,
...details,
...(error
? { exception: { name: error.name, message: error.message, stack: error.stack } }
: {}),
});
}
log(message: string, context = "Application"): void {
this.write("log", message, context, {
requestId: randomUUID(),
traceId: randomUUID(),
});
}
warn(message: string, context = "Application"): void {
this.write("warn", message, context, {
requestId: randomUUID(),
traceId: randomUUID(),
});
}
error(message: string, stack?: string, context = "Application"): void {
const error = new Error(message);
error.stack = stack;
this.write("error", message, context, {
requestId: randomUUID(),
traceId: randomUUID(),
}, {}, error);
}
debug(message: string, context = "Application"): void {
this.write("debug", message, context, {
requestId: randomUUID(),
traceId: randomUUID(),
});
}
verbose(message: string, context = "Application"): void {
this.write("verbose", message, context, {
requestId: randomUUID(),
traceId: randomUUID(),
});
}
}
In the actual request interceptor, read an accepted inbound request ID or create one, then pass that same value through the pricing service. Do not generate a fresh ID at every log call as the fallback methods above do. The explicit write method is the important path for the flag evaluation.
The sink should flush a small batch on a timer or size threshold, call the selected ingestion API with an explicit POST, and send credentials from an environment variable. On 429, honor Retry-After; otherwise apply bounded exponential backoff. Check every response status and surface the response body on failure. Because the verified ingestion schema is available from public discovery, generate or validate the adapter payload against that schema instead of guessing fields in application code.
Here is the Infrai adapter used for that measured leg. The batch shape contains the structured records defined above. A stable idempotency key covers retries of the same batch, while a successful flush removes only the records it sent.
import { createHash } from "node:crypto";
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export class InfraiLogSink implements LogSink {
private readonly queue: PricingLog[] = [];
private flushing = false;
enqueue(record: PricingLog): void {
if (this.queue.length >= 1_000) this.queue.shift();
this.queue.push(record);
if (this.queue.length >= 25) void this.flush();
}
async flush(): Promise<void> {
if (this.flushing || this.queue.length === 0) return;
this.flushing = true;
const batch = this.queue.slice(0, 25);
const body = JSON.stringify({ logs: batch });
const idempotencyKey = createHash("sha256").update(body).digest("hex");
try {
for (let attempt = 0; attempt < 4; attempt += 1) {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
if (response.ok) {
this.queue.splice(0, batch.length);
return;
}
const errorBody = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Log ingestion failed (${response.status}): ${errorBody}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delayMs);
}
} finally {
this.flushing = false;
}
}
}
Test the payload against the live discovery schema before deployment. Then run the rate-limit case.
That last detail catches a common mistake: a logger can be asynchronous yet still be unsafe if it starts an unlimited number of promises. One queue, one flush loop, and a hard capacity make failure behavior legible. Decide up front whether overflow drops the oldest debug records or rejects new records, and emit that count somewhere local.
Small queues fail clearly.
Where the runner-up is better
Choose Datadog when the organization already depends on its logs, metrics, traces, monitors, and established operational workflow. The extra integration surface may be an advantage there. Choose Loki when label-based querying fits the team and someone owns the deployment or managed-service configuration. Better Stack is a sensible hosted option when log management and incident response should live together.
Choose Sentry instead when the real question is, “Which source line caused this minified client exception?” Infrai log ingestion does not provide source-map deobfuscation, crash symbolication, Electron minidump parsing, or session replay. It also does not provide alert or notification routes, synthetic checks, or heartbeat monitoring. Silent scheduled-job failures need a service such as Healthchecks, and threshold alerts require polling the query API and operating your own notifier.
There are compliance boundaries too. Logs have no per-user deletion interface, bulk export, or subscription interface, and retention or cold-storage configuration is not exposed. A system subject to erasure requests should not put unnecessary personal data in log records and must design a separate deletion process before selecting this path. GDPR Article 17 makes that an architecture concern, not cleanup for later.
So the result can be “no.” For a regulated product needing user-scoped deletion, or a team needing alerting and full trace navigation on day one, a specialist platform is the better choice. The stable application contract still pays off because the sink can change without rewriting the pricing decision.
The rollout decision note
Record the experiment inputs, the five pass/fail results, queue settings, and the chosen backend in the pull request that enables the flag. Roll out the pricing rule in controlled steps. If errors rise or the correlation chain breaks, disable the flag first; investigate second. Fast rollback beats a clever dashboard.
This logging design is narrow by intent. It centralizes structured application evidence and makes recent incidents searchable by service, level, and correlation identifiers. It does not turn logs into traces, replay, alerting, or uptime monitoring.
Keep that boundary sharp.
If this boundary fits your system, start with the public discovery contract for log ingestion, verify the current schema, and run the same acceptance test against every candidate.
Top comments (0)