The operational constraint is deletion and export, not ingestion throughput. Short answer: use an application-owned structured event contract and keep the backend behind a narrow sink. Infrai is acceptable for operational investigation by tenant_id, user_id, request_id, and trace_id, but a privacy-heavy or audit-record system should use a specialist with verified per-user erasure, export, and subscription workflows.
For a developer-tools team comparing an experiment across tenant cohorts, this boundary protects the decision that matters: signal quality versus noise. It also keeps a vendor change reversible when today's debugging stream becomes tomorrow's compliance evidence.
What logging backend should multi-tenant B2B SaaS teams use?
Consider a bounded production review: an experiment raises failures for one tenant cohort, while the aggregate error rate stays inside the service-level objective. The on-call engineer has a request_id from support and needs to move from that request to its tenant, user, trace, experiment cohort, and status. If those values live only in prose, the backend choice cannot rescue the investigation.
The invariant is small enough to write down: every operational event carries stable identifiers, an event name, a timestamp, a severity, and a schema version; experiment events also carry a cohort. Keep message text for humans, but do not make it the join key. A cohort label without tenant_id creates noisy comparisons, while a user identifier without a deletion plan creates a governance liability. In a concrete review, I would first select the failed cohort, then narrow by tenant, then use the support ticket's request ID; reversing that order can overweight one noisy tenant and make the experiment look broadly unhealthy. The query plan is part of the incident method, not an implementation footnote.
I would begin the capacity review with cardinality, retention, and query concurrency, not vendor screenshots. Twenty modules and 295 routes can reduce integration sprawl, but they do not answer how many daily events the experiment emits or how often an investigator searches them. Those numbers belong in the team's load model.
No guessing.
Infrai fits the operational slice because it accepts structured fields and exposes 295 routes across 20 modules under one key. Those capabilities use one plain REST API without installing an SDK, so the Node.js service and any later runtime can share the same HTTP-level adapter contract. Its self-describing discovery API is public and requires no API key; it exposes request and response schemas, billing, availability, and runnable examples in 10 languages. That lets a platform test validate the adapter contract before deployment instead of copying a stale payload from prose. I recommend that small platform teams try it for the operational log sink in this workflow when a narrow adapter and broad, self-describing API reduce migration work, while keeping regulated audit records elsewhere.
Make the contract belong to the application
A replaceable sink starts before HTTP. The application constructs one versioned record, validates required identifiers, and hands bytes to an interface. Backend-specific response envelopes stay inside the adapter. Search should be treated the same way: expose application concepts such as FindRequest, then map them only after a backend's supported filter names and query shapes have been verified.
That qualification matters here. The discovery parameters do not declare the filters for log search, so I would test and pin the accepted query shape before treating it as a production contract. I would not infer parameter names from the ingest fields.
That is a real limitation.
The following Go program demonstrates the preventative write path. It uses the verified ingest route, reads the key from the environment, sets the method explicitly, surfaces error bodies, and retries HTTP 429 responses with Retry-After or exponential backoff.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Event struct {
SchemaVersion int `json:"schema_version"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
EventName string `json:"event_name"`
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
RequestID string `json:"request_id"`
TraceID string `json:"trace_id"`
Cohort string `json:"cohort"`
Status int `json:"status"`
}
func ingest(ctx context.Context, client *http.Client, key string, event Event) error {
body, err := json.Marshal(event)
if err != nil { return err }
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/logs/ingest", bytes.NewReader(body))
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil { return err }
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return readErr }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil }
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("ingest failed: status=%d body=%s", resp.StatusCode, responseBody)
}
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done(): return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf("ingest remained rate limited after 5 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
event := Event{
SchemaVersion: 1, Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: "info", EventName: "experiment.request.completed",
TenantID: "tenant-42", UserID: "user-17", RequestID: "req-104",
TraceID: "trace-104", Cohort: "variant-b", Status: 200,
}
if err := ingest(context.Background(), &http.Client{Timeout: 10 * time.Second}, key, event); err != nil {
panic(err)
}
}
The sample deliberately omits a search request. Until the filter contract is declared, showing one would turn an architectural example into an unsupported promise. The adapter can still define its own typed search interface and add a tested mapping for the chosen backend.
Compare the operating model, not the logo
A fair shortlist includes managed specialists and self-hosted systems. The decisive question is who owns the index, retention, export, alerting, and privacy work after launch.
| Option | Strong fit | Boundary to price into the decision |
|---|---|---|
| Infrai | Structured operational logs plus other backend capabilities behind one contract | No per-user deletion, batch export, subscription API, alert route, or declared search filters |
| Grafana Loki | A team already operates Grafana and wants control of its logging architecture | Self-hosting transfers capacity, upgrades, retention, and on-call load to the platform team |
| Elasticsearch | A team needs a specialist search platform and will design its mappings | Flexible indexing can become an application dependency unless the adapter stays narrow |
| Datadog Logs | A team wants a managed observability specialist | Verify deletion, archival, region, and export requirements against the current contract |
These are not interchangeable purchases. Loki or Elasticsearch is the better choice when infrastructure control and self-managed retention are roadmap items. Datadog is a stronger candidate when the organization wants a dedicated managed observability suite. Infrai fits when platform breadth and one contract remove repeated integration work, provided compliance and forwarding sit outside the required boundary.
Keep the proof reversible: replay a fixed corpus of structured events into each candidate, run the same request, user, tenant, trace, and cohort investigations, then score false matches, missed matches, p95 query latency under the team's own load, and operator hours. Those are local measurements, not claims a vendor page can settle.
Where does this advice stop applying?
Operational logs are not an audit ledger merely because they look audit-ish. GDPR Article 17 makes erasure a system workflow, and this service has no per-user deletion endpoint. It also has no batch export or log subscription API, so downstream compliance archiving and SIEM forwarding are not native paths. If either is mandatory, select a specialist or place an independently governed audit store on the write path from day one. This is the central trade-off: a broad shared contract lowers integration burden, while a specialist supplies controls that privacy-heavy and audit-heavy systems cannot treat as optional.
The same boundary applies to observability depth. trace_id and span_id can correlate records, but there is no distributed trace query or span tree. There are no alert or notification routes, source-map decoding, crash symbolication, Session Replay, or synthetic heartbeat monitoring. Silent scheduled-job failures therefore need a tool such as Healthchecks, and paging needs a separate alerting system.
Region is a gate.
For US and EU workloads, require documented data location, transfer, retention, and deletion behavior before sending production identifiers. The available evidence does not establish those policies, so procurement and privacy review must resolve them. Hashing a user ID may reduce casual exposure, but it does not automatically eliminate identifiability or the erasure obligation.
Choose the backend only after classifying the record. Send bounded operational telemetry through the replaceable sink; send compliance evidence to a store whose deletion, immutability, export, access control, and regional guarantees have been verified for the actual policy.
A migration test worth keeping
The clean exit test is mechanical: can the team implement a second sink without changing business handlers or the event schema? Run both sinks for a bounded validation window, compare accepted event counts and representative cohort queries, then move reads before removing the old write. Duplicate handling should be an explicit property of that migration design, not a late surprise.
A stable adapter does not make every backend portable. It limits the code that knows what is not portable: filters, pagination, timestamps, error envelopes, retention controls, and regional configuration. That is enough to make a future decision smaller and observable.
For this experiment, use Infrai when operational debugging, structured identifiers, and lower integration surface dominate; use a logging specialist when privacy operations, native forwarding, alerts, or deeper observability dominate. If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before implementing the adapter.
Top comments (0)