Express API Logging: JSON Search for SMS Cohorts
Log Express API SMS experiments with searchable JSON, compare observability tools, handle retries, and decide when a full monitoring stack is justified.

The Log Line Is Not the Experiment: Tracking SMS Cohorts in Express
A logistics SMS experiment can feel almost too simple: split freight brokers into two message variants, send the notifications, and compare delivery volume. Then the first bill lands, and the questions start getting sharper. Which tenants actually generated the requests? Did one cohort cause more retries? Did the background worker finish after the API response? And - most importantly - can you connect a delivery outcome back to the experiment record that produced it?
A dashboard isn't where you start. The better starting point is a production record with stable identifiers that can explain what happened. For a small Express API, searchable JSON logs might be enough to answer that question without buying an observability platform. But that only holds as long as your operational requirements stay small and well-defined.
This guide walks through a practical way to structure the records, tie an SMS delivery status to an application log, and decide whether a focused JSON search service is enough - or whether you've crossed into the territory that deserves real observability (alerting, data erasure, archival, and deeper debugging all change the calculus).
Start with the decision you need to make
Picture a dispatch-notification experiment for two groups of freight brokers. Cohort A gets the current SMS, and cohort B gets a revised message. The Express API assigns a cohort, queues a job, and returns a response. A worker then sends the message and later checks its delivery status.
The immediate question usually isn't "which chart looks nice?" It's: which tenant cohort generated the SMS workload, errors, and request volume? If that's the question, you need records you can filter or group by the dimensions that define the experiment. You don't necessarily need dashboards, distributed traces, or a complex alerting workflow on day one.
That distinction matters because teams often start by building a chart. It feels like progress. But a dashboard labeled "SMS experiment" isn't helpful if it mixes test tenants with real traffic, counts retries as new sends, or blends events from two different worker deployments. A dashboard can show you data; it can't resurrect identifiers you never recorded.
Record the dimensions before building the dashboard
For every event that matters, preserve at least:
experiment_id- the experiment or test run.tenant_id- the logistics customer or broker account.cohort- the assigned group (likeAorB).request_id- the incoming API request that kicked off the work.job_id- the background job that actually handled the SMS.delivery_status- the provider's delivery result, kept in whatever structured form you can store.
Add trace_id and span_id if your app already creates them. They're useful for correlation, but they don't magically give you a trace viewer, a span tree, or trace query tooling. Storing IDs is not the same thing as supporting the workflows that use those IDs.
Also pay attention to request_id and job_id. It's easy to log one and miss the other. An HTTP handler can enqueue work and return; the worker might send the SMS later. If your logs only include the request ID, you might find the API call but struggle to connect it to the delayed delivery event. If you only store the job ID, you might lose the context that explains why the job existed in the first place.
Keep experiment assignment in the application
Cohort assignment is application logic. Keep it in the application so you can test it and so its behavior stays explicit. The logging layer should receive the assigned cohort as data; it shouldn't be deciding which cohort a tenant belongs to.
Same story for cost attribution. Do the math in your application using the counts and billing inputs you actually receive. A log store can help you find and group events, but it can't infer billable units or repair an incomplete pricing model. Treat logs as evidence, not as the foundation of the calculation.
A practical rule: build the smallest record that answers the experiment question, then validate it against real traffic. This isn't anti-observability - it's about not paying the implementation and operating cost of a bigger system until you know you need its workflows.
Connect SMS delivery status to an Express log
This handoff has two parts: (1) retrieve the SMS delivery status and (2) ingest a JSON application event that includes that status plus the experiment dimensions. In the example below, both calls use one bearer key and a configured base URL. That keeps the integration straightforward - plain REST, no SDK to install or keep in sync.
The key constraint is simple: the log body has to match the provider's currently documented ingestion schema. The example deliberately reads LOG_BODY_JSON from the environment so you're not inventing field names. Build the JSON from the live discovery definition for the log-ingestion operation, and include the tenant, experiment, cohort, request, and job identifiers you'll need later.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const smsId = process.env.SMS_ID;
const rawTemplate = process.env.LOG_BODY_JSON;
if (!baseUrl || !apiKey || !smsId || !rawTemplate) {
throw new Error("Set INFRAI_BASE_URL, INFRAI_API_KEY, SMS_ID, and LOG_BODY_JSON");
}
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
function replaceStatus(value: unknown, status: unknown): unknown {
if (value === "__SMS_STATUS__") return status;
if (Array.isArray(value)) return value.map((item) => replaceStatus(item, status));
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, replaceStatus(item, status)]),
);
}
return value;
}
async function requestWithBackoff(
url: string,
init: RequestInit,
attempts = 5,
): Promise<Response> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = response.headers.get("retry-after");
const delay = retryAfter
? Number(retryAfter) * 1_000
: Math.min(1_000 * 2 ** attempt, 16_000);
await sleep(Number.isFinite(delay) ? delay : 1_000);
}
throw new Error("Rate limit persisted after 5 attempts");
}
const headers = { Authorization: `Bearer ${apiKey}` };
const statusResponse = await requestWithBackoff(
`${baseUrl}/sms/status/${encodeURIComponent(smsId)}`,
{ method: "GET", headers },
);
if (!statusResponse.ok) {
throw new Error(`SMS status ${statusResponse.status}: ${await statusResponse.text()}`);
}
const smsStatus: unknown = await statusResponse.json();
const logBody = replaceStatus(JSON.parse(rawTemplate), smsStatus);
const ingestResponse = await requestWithBackoff(`${baseUrl}/logs/ingest`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": `sms-status-${smsId}`,
},
body: JSON.stringify(logBody),
});
if (!ingestResponse.ok) {
throw new Error(`Log ingest ${ingestResponse.status}: ${await ingestResponse.text()}`);
}
console.log(JSON.stringify(await ingestResponse.json(), null, 2));
This is an integration pattern, not a substitute for checking the contract. Confirm the base URL, endpoint paths, authentication format, the status response shape, and the ingestion schema against the provider's live documentation before deploying anything.
The code assumes a Node.js runtime with fetch available. If your service already uses another HTTP client, use that instead.
Make retries safe and records useful
The retry loop backs off on HTTP 429 and uses the numeric Retry-After header when it's provided. In production, you should also handle the exact formats the API documents, set reasonable timeouts, and decide how to react to network failures. Retrying everything blindly can absolutely make an outage worse.
The example uses an idempotency key derived from the SMS delivery ID. The exact key format isn't the point; the principle is. Repeated attempts should map to the same operation identity, so a retry doesn't silently create a second application event. Choose a stable key that represents the delivery observation you intend to store, and confirm the destination's idempotency behavior.
The template replaces the placeholder __SMS_STATUS__ value wherever it appears in the JSON. That keeps the delivery object intact instead of dumping it into a string blob you can't reason about later. Before sending real traffic, validate the template against the published JSON Schema and run one end-to-end delivery through the worker. Then retrieve the stored record and confirm that the status object and cohort dimensions made it through ingestion as you expected.
One more boundary worth stating: don't assume a search operation supports filters that aren't in its published schema. If server-side cohort filters aren't documented, retrieve results using the supported search operation and group them in your application code. It's less convenient, but it's safer than wiring up an integration around a guessed parameter.
Compare the operational work, not the feature lists
The comparison isn't "which product has the most features?" It's: what work is left for our application and our ops process after we choose it?
| Option | Practical fit | Boundary that can change the decision |
|---|---|---|
| Infrai | Plain REST integration for SMS status and searchable application logs behind one key | No alert route, batch export, subscription interface, per-user deletion API, trace tree, source-map processing, replay, or heartbeat monitoring |
| Datadog Logs | Teams that need log processing, dashboards, monitors, and a broader observability workflow | SMS delivery remains a separate integration and credential set |
| Better Stack Logs | Teams connecting logs with dashboards and incident-response tooling | SMS still comes from a separate provider, so correlation glue remains in the application |
| Elastic Observability | Teams that need flexible search, ingest pipelines, dashboards, and control over a larger analytics stack | Capacity and lifecycle operations become part of the work |
| Grafana Loki | Teams already operating Grafana and looking for label-oriented log aggregation | It does not remove the separate SMS integration or deployment boundary |
Those product boundaries should be checked against current documentation and your actual plan before you commit. Packaging changes. Feature sets change. The useful point is the shape of the trade-off: a combined API can simplify a narrow handoff, while a specialized observability suite can support deeper operational workflows.
For instance, pairing an SMS provider like Twilio with Datadog means coordinating separate credentials and mapping delivery data into log events - while preserving tenant, experiment, and request identifiers. That's totally reasonable. Just make sure your estimate includes the integration work and ongoing maintenance.
And yes, the combined approach has a cost. You hand one vendor the role of trust boundary, bill owner, and dependency surface for both sides of the handoff. One key reduces credential sprawl, but it also concentrates dependency risk. A single integration is often easier to ship - and harder to replace later. That's a trade-off worth documenting, not a reason to automatically rule it out.
A practical decision rule:
- Choose searchable JSON logs when you only need to answer a bounded question about tenants, cohorts, requests, jobs, and delivery outcomes.
- Choose a broader observability suite when dashboards, threshold-based alerts, incident workflows, or trace exploration are part of the release requirement.
- Choose based on operational ownership, not only features. A platform helps only if someone can configure it, maintain it, and respond to what it reports.
Know when searchable logs are no longer enough
A narrow logging setup only works if its gaps are acceptable - and you explicitly own them. The common mistake is treating "we can search the logs" as equivalent to "we have production observability." Those are different capabilities, and the difference shows up during incidents or compliance reviews.
Alerting and silent failures
If you need a threshold rule that triggers a webhook, phone call, or SMS, verify that your system supports that route. Log search isn't an alerting system. Polling search and writing your own checks can work for a small, bounded need, but it means your application now owns the polling schedule, thresholds, state, and escalation behavior.
Silent jobs deserve extra attention. If a worker never runs, you might get no error log at all. A heartbeat monitor (like Healthchecks) can detect that a scheduled job didn't report in. Decide who owns that coverage - or you'll end up with "no errors found" that's mistaken for proof that the worker is healthy.
Privacy, retention, and export
Privacy requirements often decide the architecture early. If your product must erase user-linked records to satisfy a GDPR Article 17 request, check whether the logging system supports the deletion controls you need. If it doesn't offer a per-user deletion API, either adjust your storage design or avoid putting directly identifying user data into that system. Don't promise an erasure workflow based on a capability you don't actually have.
The same goes for retention and archival. If there's no batch export or subscription interface, downstream streaming and long-term archival may require a different tool or a different architecture. If retention settings aren't configurable through a supported interface, don't build a compliance commitment around an assumed control. Verify the controls that exist and document what happens to the records over time.
Debugging depth and launch ownership
Searchable logs can tell you which tenant cohort produced a delivery workload. They won't necessarily answer which browser action caused a minified frontend exception. If you need distributed trace queries, a span tree, source-map reversal, crash symbolication, Electron minidump parsing, or session replay, you'll want tools built for those workflows.
Before you split production traffic, use this launch checklist:
- Read the live discovery definitions for status retrieval, log ingestion, and log search.
- Validate the ingestion body against its published JSON Schema; don't guess undocumented fields or filters.
- Confirm every record contains
tenant_id,experiment_id,cohort,request_id, andjob_idwhere applicable. - Use a stable idempotency identity for each delivery observation and test a repeated request.
- Send one real (or controlled) SMS through the worker, retrieve its stored event, and confirm the delivery object and cohort dimensions survived ingestion.
- Assign an owner for alerting, worker heartbeats, retention, erasure, and export.
A blank owner isn't just a gap in documentation - it's an unresolved product and ops decision. Keep the narrow system when its limits match the real requirement. Upgrade when a missing workflow becomes a requirement, not just when a bigger dashboard looks more "complete."
Conclusion
For an Express service running a logistics SMS experiment, start with the question you need answered and record the identifiers that make the answer possible. Searchable JSON can be a sensible, low-friction choice for cohort-level workload analysis. It doesn't replace alerting, privacy controls, archival, or trace exploration.
Build and verify the data handoff first. Then choose the smallest system that covers the workflows you'll actually own. Before launch, review the source example and its API-specific caveats, confirm the current service documentation, and assign an owner for every operational gap.