Pick the backend by the cost question you have to answer, not by the chart library you like. When a nightly Node.js pipeline has to explain which portfolio burned the compute, the attribution key belongs in every structured log line and in every batch of metrics you ship — before the internal admin panel renders a single KPI.
That ordering is the whole argument here.
The system I'll use throughout is a property management back office: one nightly job that pulls utility meter readings, applies rent ledger deltas, and closes out work orders for roughly 40 buildings across four portfolios. Finance wants a cheap internal dashboard that says which portfolio costs the most to process. Engineering wants to search last night's structured logs when the meter step runs long. Those are different queries, and pretending otherwise is how a small admin panel turns into a line item nobody can defend.
| Where the KPI rows live | Pick it when | The trade-off |
|---|---|---|
The pipeline's own Postgres, one run_step table |
Rows per night are in the millions or fewer and the panel reads recent days | You own retention, indexes, and vacuum behavior |
| JSON Lines in object storage, scanned on demand | Raw lines must be kept cheaply and are queried rarely | Query latency is seconds to minutes, not milliseconds |
| Self-hosted log search (Loki, OpenSearch) | Engineers need free-text search across runs during an incident | Another cluster to size, upgrade, and staff |
| Hosted log or metrics platform billed per ingested GB | Nobody on the team wants to operate storage | Volume drives the bill, so cardinality becomes a budget decision |
| Time-series store with recording rules (Prometheus-style) | The KPI is a number over time rather than a searchable event | Labels must stay low-cardinality, so per-unit breakdowns don't fit |
Feature lists move around. Those trade-offs have stayed put for years, which is why they make a better decision axis than a vendor comparison.
What should an internal admin panel query for batch KPI metrics from a nightly pipeline?
Pre-aggregated rows. Almost never the raw log stream.
Two questions arrive at the same table and want opposite things from it. The panel asks "what did portfolio 7 cost to process last night?" — a handful of rows, sub-second, on every page load, from a session that belongs to a finance user who should never see tenant identifiers. An engineer asks "why did the meter-reading step take 40 minutes on Tuesday?" — thousands of lines, full-text, once a month, and nobody minds if the answer takes ten seconds to come back. Serving both from one hot store is the most common way an internal dashboard gets expensive, because you end up paying interactive-query prices for data that is read twice a quarter.
The shape that holds up looks like this in words: nightly job → structured JSON Lines on stdout → one batch ingestion call per run → a daily rollup table → a Next.js route handler → the chart component. Each arrow is a place you can change providers without touching the one downstream from it.
Keep the credential on the server side of that chain. The browser calls your own route, that route calls the metrics backend API, and the response the browser gets is already filtered to what this admin role is allowed to see. Nothing about that is exotic — it's the same boundary you'd draw around any internal tool — but it's the difference between rotating one key and auditing every dashboard user.
Put the cost dimension in the log line, not in the query
Attribution is a write-time decision. You can't reconstruct which portfolio a batch of API calls belonged to after the fact unless the run wrote it down, and re-parsing a month of logs to backfill a column is the kind of chore that quietly never happens.
In property management the unit of blame is a portfolio or a building, rarely a service. So every line the nightly job emits carries portfolio_id, property_id, step, rows_in, rows_out, duration_ms, and vendor_calls — that last one matters because metered third-party lookups (address validation, utility provider APIs) are usually the largest variable cost in the run, and they're invisible in CPU metrics. For severity, reuse the numeric levels from RFC 5424 rather than inventing a scale: 6 for informational, 4 for warning, 3 for error. The OpenTelemetry logs data model maps onto the same severity range, so a later migration to a collector-based pipeline doesn't invalidate the rows you already stored.
The dimension you can afford to keep is the dimension you can afford to store. Four portfolios and 40 buildings is nothing — a few hundred distinct label values. Twelve thousand rental units is a different budget entirely, and on a per-GB-ingest platform it's the difference between a rounding error and a renewal conversation. My rule of thumb: attribute at the level finance actually charges back, keep the finer identifiers inside the raw log body where they're searchable but not indexed, and accept that per-unit breakdowns are a query you run against the warehouse, not a chart on the panel.
The ingestion path in code: one attributable batch per run
Collect in memory, write JSON Lines as you go, ship one batch when the run finishes. The idempotency key is the run id, so a retry after a network hiccup writes the same batch once instead of doubling every counter on the dashboard.
type StepLog = {
ts: string;
level: number; // RFC 5424 severity: 6 informational, 4 warning, 3 error
run_id: string;
portfolio_id: string;
property_id: string;
step: "meter_readings" | "rent_ledger" | "work_orders";
rows_in: number;
rows_out: number;
duration_ms: number;
vendor_calls: number;
};
const batch: StepLog[] = [];
export function record(entry: StepLog): void {
batch.push(entry);
process.stdout.write(JSON.stringify(entry) + "\n"); // one JSON object per line
}
export async function ship(runId: string, attempt = 0): Promise<void> {
const res = await fetch(`${process.env.METRICS_URL}/batch`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.METRICS_TOKEN}`,
"content-type": "application/json",
"idempotency-key": runId,
},
body: JSON.stringify({ lines: batch }),
});
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((r) => setTimeout(r, waitMs));
return ship(runId, attempt + 1);
}
if (!res.ok) throw new Error(`batch rejected: ${res.status}`);
}
Two details in there earn their keep. Honoring Retry-After instead of hammering a fixed backoff is what keeps a rate-limited worker from turning one slow night into a thundering-herd night. And writing to stdout in the same call that fills the batch means the raw evidence survives even when the ingestion call never lands — the log file is the source of truth, the batch is a convenience.
The panel reads a rollup, which the same job upserts:
insert into kpi_daily (day, portfolio_id, step, rows_out, seconds, vendor_calls)
select date(ts), portfolio_id, step, sum(rows_out), sum(duration_ms) / 1000.0, sum(vendor_calls)
from pipeline_log
where ts >= current_date - interval '1 day'
group by 1, 2, 3
on conflict (day, portfolio_id, step) do update
set rows_out = excluded.rows_out,
seconds = excluded.seconds,
vendor_calls = excluded.vendor_calls;
Re-running the job re-runs the upsert. No duplicate bars.
Retention, cardinality, and who pays for the logs you keep
Ingestion volume is the bill on most hosted platforms, and volume is something your own code controls. Sample the debug chatter — one line in fifty is plenty for a step that logs per record — and never sample the run summary. Keep the rollup rows forever, because they're small and finance will ask about last March. Keep raw lines for 14 to 30 days, long enough to debug a run that went sideways, short enough that a bad log-level change doesn't cost you 30 GB before anyone notices.
Then attribute the observability spend itself. If the nightly job is 80% of your ingested bytes and one portfolio's meter feed produces most of that, put that number on the same dashboard as the pipeline cost. Teams that skip this step end up with an internal tool that reports on everything except the tool.
One honest caveat: I can't tell you which of these matters most in your setup, because it depends entirely on whether logs or vendor calls dominate your bill. Measure one week before you optimize.
Failure modes: the run that logged nothing
The failure that hurts isn't a noisy run. It's a silent one.
A nightly job that exits early, or gets skipped because the scheduler and the database disagreed about a daylight-saving boundary, produces no logs at all — and an absence looks exactly like a quiet, successful night on a bar chart. Assert the opposite: after ingestion, check that each portfolio has a summary row for the expected day, and page on the missing row rather than on a threshold. A separate heartbeat check that watches for "the job did not report" covers this from outside the system that failed, which is the only place it can be observed reliably.
The catch with the whole batch-at-the-end design is latency. You learn about last night at 3:07 a.m. and not a second sooner, so this is not a good fit if someone needs to intervene mid-run; that argues for per-step emission and a streaming sink, at meaningfully higher cost. It's also not suitable when the same data has to serve product analytics — funnels, cohorts, retention curves — because a rollup table has already thrown away the event grain those questions need. Stick with a warehouse plus an event pipeline when that's the real requirement, and treat the admin panel as one more consumer of it.
Everything above assumes the cost question is the one being asked. Change the axis to latency or compliance and the table at the top reorders itself — which is fine, as long as the reordering happens before the schema does.
References
- https://datatracker.ietf.org/doc/html/rfc5424
- https://opentelemetry.io/docs/specs/otel/logs/data-model/
- https://jsonlines.org/
- https://www.postgresql.org/docs/current/datatype-json.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
- https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/
Top comments (0)