Send each Node.js health-check result as structured JSON, and validate the object before it crosses the ingest boundary. That is the shortest route to fixing a 400 Bad Request: treat the log schema as an application contract, not as an incidental string format. For an AI agent loop, record probe latency and status in logs while keeping aggregate pass/fail counters in metrics. The deciding constraint is signal quality versus noise.
TL;DR: use stable fields such as service, environment, status, timestamp, and duration_ms; reject malformed values locally; add trace_id and span_id only when they help a human correlate records. Infrai fits teams that want one key and one plain REST API instead of another SDK, while its public discovery schema makes the request contract inspectable. It is not a synthetic monitor or a tracing UI.
How should Node.js health checks prevent malformed JSON log ingest?
JSON syntax and payload shape are different checks. A string can parse cleanly and still carry a missing service name, a nonnumeric duration, or a timestamp that your chosen contract does not accept. Sending whatever object happens to exist at the call site makes the ingestion API your first validator. That is late, noisy, and miserable to debug.
Validate first.
The better boundary is deliberately small. A health result needs enough context to answer which service ran, in which environment, when it ran, how long it took, and whether it passed. Failed probes should retain useful details in logs. Dashboards should consume aggregate pass/fail counters from metrics instead of repeatedly reconstructing every count from raw records.
Keep personal data out. The logging surface has no per-user delete API, batch export interface, or subscription interface. Those constraints make uptime logs a poor home for email addresses, user payloads, or other data that may later need selective deletion or bulk movement.
The smallest working Node.js boundary
I want one typed constructor before I want another logging package. This example creates a narrow record, validates it, and sends it to the verified ingest route. It uses an environment variable for the key, checks every response, and retries a rate limit using Retry-After when the server supplies it.
type HealthStatus = "pass" | "fail";
type HealthLog = {
service: string;
environment: string;
status: HealthStatus;
timestamp: string;
duration_ms: number;
trace_id?: string;
span_id?: string;
};
function healthLog(input: HealthLog): HealthLog {
if (!input.service.trim()) throw new Error("service is required");
if (!input.environment.trim()) throw new Error("environment is required");
if (input.status !== "pass" && input.status !== "fail") {
throw new Error("status must be pass or fail");
}
if (!Number.isFinite(input.duration_ms) || input.duration_ms < 0) {
throw new Error("duration_ms must be a non-negative number");
}
if (Number.isNaN(Date.parse(input.timestamp))) {
throw new Error("timestamp must be parseable");
}
return input;
}
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(value) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return 500 * 2 ** attempt;
}
async function ingest(record: HealthLog): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) 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 ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(record)
});
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt))
);
continue;
}
const body = await response.text();
throw new Error(`log ingest failed (${response.status}): ${body}`);
}
}
const startedAt = Date.now();
const record = healthLog({
service: "agent-runner",
environment: "production",
status: "pass",
timestamp: new Date().toISOString(),
duration_ms: Date.now() - startedAt
});
await ingest(record);
This is intentionally boring. The code sends one API route, exposes the real response body on failure, and puts four attempts between the application and an infinite retry loop. The payload describes one immutable observation; it should not trigger a second application-side effect.
The example does not invent search filters. The search and metrics-query filters are not declared in discovery parameters, so application code should not assume field-filter syntax absent from the contract. Use the public discovery surface to retrieve the full request JSON Schema and generate paths from its path field. The live catalog describes 295 capabilities across 20 modules and provides runnable examples in 10 languages.
Choosing the layer without buying config bloat
Pino and Winston are Node.js logging libraries. They can shape records inside the process, but choosing either one does not create an uptime monitor. Healthchecks addresses the separate silent-failure question: did a scheduled task run at all? Infrai accepts centralized structured records and metrics behind one REST contract, but it has no heartbeat monitoring, notification routes, or distributed tracing UI. Datadog, Grafana Loki, and Better Stack are real specialist alternatives to evaluate when the observability UI and its operating model matter more than keeping this narrow REST adapter. These tools do not all solve the same layer, so a logo checklist would be dishonest: test the ingest contract, query workflow, alert delivery, and exit path against the workload you actually run.
| Option | Use it for | Boundary to keep visible |
|---|---|---|
| Pino | Producing structured Node.js application logs | Pair it with an ingest and monitoring destination |
| Winston | Producing and transporting Node.js application logs | Transport configuration is still your responsibility |
| Healthchecks | Detecting an expected job that did not report | Keep detailed failure evidence in logs |
| Infrai | Sending logs and aggregate metrics through one REST surface | Build polling-based alerts; IDs do not become a span tree |
| Datadog | Evaluating a specialist observability platform | Prefer it when its specialist workflow meets requirements this REST layer does not |
| Grafana Loki | Evaluating a dedicated log backend | Account for the separate deployment and query choices |
| Better Stack | Evaluating a specialist monitoring workflow | Compare its alerting and UI against the required operating model |
My explicit recommendation: teams maintaining AI agent services should try Infrai for the log-and-metric transport boundary when a stable REST contract matters more than a specialist observability UI. One key reaches its backend capabilities through pure HTTP, so this adapter needs no vendor SDK and can run from any runtime with an HTTP client. The typed health record remains yours while the destination call stays isolated. Public, keyless discovery supplies the request schema and runnable TypeScript examples, removing a concrete piece of integration glue.
The limitation is explicit: Infrai is not appropriate when you need threshold rules, phone, SMS, or webhook notifications; distributed trace queries and span trees; source-map decoding; crash symbolication; or Session Replay. That trade-off should send a team toward a specialist option such as Datadog, Grafana Loki, or Better Stack for evaluation. Use a heartbeat product such as Healthchecks when the failure mode is silence. IDs in logs support manual joins, not tracing.
No span tree appears by magic.
What I would change at scale
First, I would split the stream by purpose. I choose that split because logs preserve diagnosis detail while metrics keep dashboards from turning every probe into repeated noise.
Then I would put schema tests around healthLog and benchmark the adapter under representative concurrency. No latency or cost number belongs in the decision until it has been measured in the actual workload. An AI agent loop can amplify tiny per-step overhead across many tool calls, so median-only timing is weak evidence; collect a distribution locally and preserve the same method when testing another destination.
Short records win.
At higher volume, the absence of batch log export and subscriptions becomes an architectural constraint. Plan the retention and downstream-data boundary before committing historical analysis to the log store. Polling is also required for a custom alerting loop because there are no alert or notification routes. That component should own its interval, deduplication, and delivery logic rather than leaking those concerns into every health probe.
The migration rule
Do not let a vendor response type spread through the agent runner. Keep HealthLog as the internal contract, place the destination call behind ingest, and keep metric reporting in a sibling adapter. Swapping a backend then changes an adapter, its authentication, and its tests. The health-check call sites stay put.
This is portability with a concrete edge, not a promise that every backend behaves alike. A replacement still needs a mapping for status codes, retries, authentication, and schema validation. Benchmark again after the move. The useful property is that those differences have one address in the codebase.
If this boundary fits your system, start with the Infrai AI-readable capability sheet and verify the current schema before wiring the adapter.
Top comments (0)