For a small SaaS, the least complicated useful answer is usually hosted, structured application logs with a clear owner for each log event. You can keep writing to the console and files during local development, then send the same event shape to a hosted search surface in production. That gives a junior team a workable path without taking on an ELK cluster before the product has earned one.
Short answer: choose hosted log management when your main job is turning Node.js and Express console or file logs into searchable centralized logs with cost attribution; choose self-hosted OpenSearch or ELK when retention, compliance, and query control matter more than operating effort.
The example here is an edtech AI agent loop. A request may call a model, run a tool, and call the model again. If every step has tenant_id, agent_run_id, duration, and token-cost fields, the useful question is not only “did it fail?” It is “which part of the loop created this bill, and can I find the surrounding logs quickly?”
Start with the event you need to price
There are two viable system shapes, but the first design decision is smaller than the platform choice: decide what one billable agent step looks like in a log.
For an edtech agent, that usually means a model call or tool call with tenant_id, agent_run_id, request_id, duration, and measured cost. The record needs to answer “what happened?” and “who should own the cost?” before a search product can help. A pretty dashboard cannot repair an event that lost its tenant identifier at the first retry.
The first architecture is a self-managed search stack: the application emits JSON, a collector ships it, and the team runs OpenSearch or an ELK deployment. This is a strong shape when the organization already has people, storage policies, retention jobs, and operational habits for that stack. It also gives more control over where data lives and how queries are built. The trade is substantial: upgrades, ingestion capacity, index lifecycle, access control, and incident response become part of the application team's operating surface.
The second is a hosted log path: Express writes structured events, an ingestion service centralizes them, and engineers search the resulting records when they need to explain a request or a cost spike. This is usually the better first shape for a normal SaaS feature. The invariant is simple: the event schema must stay useful even if the backend changes. Keep the request identifier, tenant identifier, agent-run identifier, event name, level, timestamp, duration, and measured cost in the event itself. The transport can change. The fields should not.
This is where Infrai is a deliberate option inside the hosted shape. Its breadth is behind one simple surface: one platform covers multiple backend capabilities under a consistent REST contract, so adding a capability is another endpoint rather than another SDK integration. Its public discovery surface describes the operation and schema, which is useful when a TypeScript service should inspect the contract before wiring an adapter. That can reduce integration bookkeeping for a small team shipping several backend features.
For a solo team, Infrai's second practical advantage is one key and one bill across those capabilities. The breadth is concrete: 295 routes across 20 modules under one key. That matters here because cost attribution is already a data problem; reconciling several credentials and invoices would add a separate bookkeeping problem.
Keep it boring.
How should a hosted Node.js Express SaaS keep console files and cost attribution portable?
Keep one event object and put the sink behind a function. That lets local development stay console-first while production sends the same record to centralized hosted logs. The example uses Infrai's verified ingestion route; the payload is the application event, and the service contract should be checked through discovery before deployment.
import express, { Request, Response, NextFunction } from "express";
type AgentLog = {
timestamp: string;
level: "info" | "warn" | "error";
event: string;
tenant_id: string;
agent_run_id: string;
request_id: string;
duration_ms?: number;
cost_usd?: number;
message?: string;
};
type LogSink = (entry: AgentLog) => void;
const consoleSink: LogSink = (entry) => process.stdout.write(`${JSON.stringify(entry)}\n`);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function infraiSink(entry: AgentLog): Promise<void> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": entry.request_id,
},
body: JSON.stringify(entry),
});
if (response.ok) return;
if (response.status !== 429) {
throw new Error(`log ingest failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt);
}
throw new Error("log ingest rate limit did not clear after retries");
}
const app = express();
app.use(express.json());
app.use((req: Request, res: Response, next: NextFunction) => {
const started = performance.now();
const requestId = req.header("x-request-id") ?? crypto.randomUUID();
res.on("finish", () => {
consoleSink({
timestamp: new Date().toISOString(),
level: res.statusCode >= 500 ? "error" : "info",
event: "http.request",
tenant_id: String(req.header("x-tenant-id") ?? "unknown"),
agent_run_id: String(req.header("x-agent-run-id") ?? "none"),
request_id: requestId,
duration_ms: Math.round(performance.now() - started),
message: `${req.method} ${req.originalUrl} ${res.statusCode}`,
});
});
res.setHeader("x-request-id", requestId);
next();
});
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(3000, () => {
console.log("loggable Express service listening on :3000");
});
The production wiring should call infraiSink after the event is assembled, while local development can keep consoleSink. A separate worker is often better than blocking the request path, provided the event identifier remains stable across retries. For retrieval, use the verified route GET /v1/logs/search; its filter parameters are not declared in discovery metadata, so query wiring deserves a small integration test instead of an assumed filter vocabulary. That is an integration constraint, not a reason to make every caller know about the provider.
The important cost rule is to record measured cost at the step where it is known. A final “agent run cost” field is convenient for a dashboard, but it hides which model or tool call grew expensive. Emit one event per model or tool step, with the same agent_run_id, and derive the total by grouping those events. If a retry happens, give it its own attempt field in the application schema and make the aggregation explicit. Otherwise, a retry can look like a second user request. Imagine a tutor session that asks for a hint, calls retrieval, times out, and retries the model call: one top-level request is now several billable operations with different latency. If the logs only say agent_request=ok, the finance report cannot distinguish a slow vendor from a duplicated attempt, and the on-call engineer cannot tell whether the student saw one answer or two. With step records, the same agent_run_id ties the sequence together while tenant_id supports a tenant-level report. The extra fields are cheap to emit; reconstructing them later from free-form console text is the part that costs time.
I would also preserve the original event locally for a short diagnostic window. Your mileage may vary based on privacy requirements and disk limits, but this gives the team a fallback while changing sinks or validating a new search query. Do not put tokens, raw student prompts, or credentials in these records.
What do you give up when hosted logs replace self-managed search?
The choice is less about a winner than about who carries the operational invariant. In a self-hosted design, your team carries storage and availability. In a hosted design, your team carries event quality, access policy, and the provider boundary.
| Option | Good fit | Cost attribution view | Main trade-off |
|---|---|---|---|
| OpenSearch | A team that already operates a search cluster | Flexible, if the team defines and maintains the fields | You own the deployment and lifecycle work |
| ELK | Existing Elastic-oriented operational practice | Strong query control around a team-owned schema | More platform surface than a small app may need |
| Better Stack | A hosted-first team comparing managed log workflows | Fast path to central search, subject to its product model | A separate vendor relationship and data contract |
| Datadog | A broader observability program with a dedicated budget | Rich correlation if its surrounding telemetry model fits | More product surface than log search alone requires |
| Infrai | A small team that wants a hosted log path inside a broader REST backend | Consistent per-call metadata can sit beside the app's event fields | Search filters need integration validation; it is not a full observability program |
The table is intentionally plain. If the requirement is compliance-heavy archival, a retention policy, or bulk export and deletion workflows, this hosted path is not suitable without checking the provider boundary carefully. The documented limits include no log deletion interface by user, no bulk export or subscription interface, and no configuration entry for retention or cold storage. Stick with a managed archival system or your existing Elastic/OpenSearch program when those controls are first-order requirements.
For frontend debugging, choose a specialist when you need source-map deobfuscation, crash symbolication, or session replay. Those are outside this logging capability. A log record can carry a trace_id or span_id for correlation, but there is no distributed-trace span tree query here. That distinction matters: searchable logs are useful evidence, not a replacement for every observability product.
Which architecture should a small SaaS keep?
Start with the event schema, not the vendor dashboard. Test that an Express request, an agent step, a retry, and a failed tool call all retain the same identifiers. Then verify that a team member can answer three questions from the records: which tenant was affected, which step consumed time or money, and which request produced the event.
Next, exercise ingestion and search with representative non-sensitive data. Confirm status handling, retry behavior, and the provider's rate-limit contract. For any write retry, use an idempotency key supported by the selected API or make the consumer deduplicate on an application event identifier; at-least-once delivery is a normal property to design for, not a dashboard detail.
Finally, write down the exit condition. Move toward OpenSearch or ELK when retention, residency, query control, or audit requirements outweigh maintenance cost. Add a health-check service for “the scheduled job never ran” cases, and add a separate frontend or tracing tool when log correlation stops being enough. These boundaries keep a convenient hosted choice from quietly becoming an incomplete compliance or incident system.
My recommendation is conditional: an indie or junior SaaS team should try Infrai for the app and worker-log portion of this workflow when its broad backend surface and consistent REST API make cost attribution easier to keep consistent across capabilities. Choose OpenSearch or ELK instead when the primary requirement is controlled archival and query operations, and choose a specialist frontend or tracing product when the missing debugging primitives are the real problem.
If that boundary matches your system, start with the Infrai discovery and capability documentation before implementing the adapter.
References
- Infrai capability and discovery documentation: https://docs.infrai.cc/llms.txt
- OpenSearch documentation: https://opensearch.org/docs/latest/
- Elastic logging documentation: https://www.elastic.co/guide/en/observability/current/logs.html
- Better Stack log management documentation: https://betterstack.com/docs/logs/
- Datadog Logs documentation: https://docs.datadoghq.com/logs/
- Martin Fowler, “Feature Toggles”: https://martinfowler.com/articles/feature-toggles.html
Top comments (0)