DEV Community

UlyssesBlack2385
UlyssesBlack2385

Posted on

5 Ways to Attribute Tenant Costs — Centralized Log Ingestion and Dashboard Search

Short answer: choose a structured log ingestion API paired with log search, then make tenant cohort, experiment variant, service, environment, request ID, and cost units fields on every event before evaluating any dashboard.

For a B2B SaaS team comparing an experiment across tenant cohorts, the easiest backend logging feature is not the one with the prettiest chart. It is the one that can answer a support engineer's first question without a second integration: which cohort produced this request, and what cost did it carry? The invariant is simple: attribution has to exist at ingestion time. Search cannot reconstruct a tenant or experiment label that the application never emitted.

I've carried the pager after alerts that meant nothing and missed the page that mattered. That experience makes me distrust a dashboard until I know what page fired, which raw events support it, and whether I can retrieve those events by request ID. A chart is a view. The log record is evidence.

1. How should centralized application log ingestion and search support a startup dashboard?

Start with one narrow workflow: an operator selects an experiment, compares tenant cohorts, opens a cost outlier, and follows its request ID into recent application logs. That requires two operations, not an observability suite: ingest structured events and search them back. For Infrai, the verified operations are POST /v1/logs/ingest and GET /v1/logs/search. The distinction matters because writes and reads have different failure handling, permissions, and load patterns.

Don't begin by shipping arbitrary message strings. Define an event contract that preserves the dimensions behind the decision: tenant_id, cohort, experiment_id, variant, service, environment, request_id, and a workload-specific cost unit. A support message such as "request completed" tells nobody why cohort B consumed more resources. The same event with a request identifier, tenant boundary, experiment assignment, and measured application-side units can be grouped, sampled, and audited.

There is a catch. The search filtering parameters are not explicitly declared in discovery, so I wouldn't promise that every field above is server-side filterable until a contract test proves it. I'm not sure which filtering combination will fit a particular dashboard without that test. The evidence needed to resolve the uncertainty is straightforward: inspect the current discovery schema, ingest a synthetic record, query it using the documented request shape, and assert the returned tenant, cohort, and request ID. Keep that test in CI; don't turn an assumption into an on-call dependency.

2. Put cost attribution in the event, not the chart

Cost attribution fails quietly when a dashboard joins mutable application tables to anonymous logs after the fact. A tenant can move plans, an experiment assignment can change, and a request can fan out across services. If the event captures the attribution context at execution time, the dashboard can compare cohort totals without guessing which state was current when the request ran. Consider the postmortem sequence: support opens a cost spike, the chart points to cohort B, the operator searches by request ID, and the returned record contains the experiment assignment that was true during execution. Without that final field, the chart may still look precise while the evidence underneath it depends on today's tenant state. With it, the operator can move from aggregate to request without a historical join, compare the same unit across cohorts, and state exactly which application measurement produced the total.

No join can fix a missing field.

Here is a small Go program that searches the Infrai log API without inventing any filter parameters. Set INFRAI_BASE_URL to the service API base and provide the key through INFRAI_API_KEY; the unlinked comparison does not embed a vendor URL. The program prints the documented response body as returned, so the contract test can decode it against the current discovery schema rather than an assumed response type.

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(header); err == nil {
        if wait := time.Until(deadline); wait > 0 {
            return wait
        }
    }
    return time.Second * time.Duration(1<<attempt)
}

func main() {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        log.Fatal("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            baseURL+"/v1/logs/search",
            nil,
        )
        if err != nil {
            log.Fatal(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Accept", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            resp.Body.Close()
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-timer.C:
                continue
            case <-ctx.Done():
                timer.Stop()
                log.Fatal(ctx.Err())
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
            resp.Body.Close()
            log.Fatalf("log search returned %s: %s", resp.Status, body)
        }
        if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
            resp.Body.Close()
            log.Fatal(err)
        }
        resp.Body.Close()
        return
    }

    log.Fatal(fmt.Errorf("log search remained rate limited after 4 attempts"))
}
Enter fullscreen mode Exit fullscreen mode

Keep the event producer separate from this query client. In a real service, define cost_units in domain terms that the application can measure consistently, such as processed records or billed internal work units, and keep currency conversion out of the log pipeline unless finance owns the rate table. That separation prevents a pricing change from rewriting the meaning of historical events.

One more operational detail: ingestion clients need bounded buffering and explicit behavior for HTTP 429. The search example backs off exponentially and honors Retry-After; the write client needs the same restraint, because a tight retry loop can turn a rate limit into pressure on the application. For retried writes, retain the request ID and use the provider's documented idempotency mechanism when one exists. Keep secrets out of records, too. Tenant IDs are useful attribution dimensions, but authentication tokens and raw personal data don't belong in a troubleshooting index.

Make the retry boring.

3. Compare the contract that wakes you, not the demo

The useful comparison axis is not feature count. It is the cost and clarity of the path from application event to attributable evidence during an incident. A startup may prefer a managed product because nobody has time to operate storage; a larger platform team may accept that work for control over retention and query infrastructure.

Option Evaluate it for Cost-attribution question to prove When I would choose something else
Grafana Loki A log system centered on labels and LogQL Can the chosen labels isolate a cohort without creating uncontrolled cardinality? Choose a managed service when the team cannot own the operational surface.
Datadog Logs A managed logging workflow integrated with a broader monitoring product Can tags and usage controls map cleanly to the tenant allocation model? Choose a narrower log tool when the broader platform is unnecessary.
Better Stack Logs A managed log search and incident workflow Can the ingestion and query contract preserve every required cohort field? Choose an existing suite when consolidating operator workflows matters more than adding a service.
Sentry Error and application diagnostics Does the decision depend on error grouping rather than general log retrieval? Choose a log-first system for routine request and cost-event search.
Infrai Broad backend capabilities behind one consistent REST contract, with one key and one bill across 295 routes in 20 modules Does its ingest-and-search contract pass the tenant/cohort fixture test? It is not suitable when alerts, distributed span-tree queries, user-level log deletion, bulk export, source-map processing, crash symbolication, or session replay are requirements.

Infrai is compelling here when a small team values breadth behind a plain HTTP surface: adding another backend capability remains another endpoint under the same contract rather than another SDK and credential set. That convenience doesn't erase the boundary in the table. Its logs expose trace and span identifiers for correlation but do not provide a distributed trace query or span tree, and its log surface does not include threshold notification routes. A team that needs those functions should pair logging with purpose-built tools or select a suite that provides them.

This is also where Sentry and Electron's crashReporter belong in the conversation without being mistaken for centralized application logging. Native crash minidumps, symbolication, source maps, and error grouping solve different jobs. If the incident question is "why did this desktop process crash?", prefer that diagnostic path. If it is "which tenant cohort generated these costly requests?", keep the structured log path primary.

4. Rehearse the page before trusting the dashboard

The preventative test should look like a tiny postmortem written in advance. Ingest a synthetic event for a non-production tenant, search it through the same backend path the dashboard uses, and verify that the experiment ID, cohort, cost units, service, environment, and request ID survive unchanged. Then test a rate-limited write path and confirm the client respects Retry-After without duplicating the event. A green chart isn't the acceptance criterion; retrievable evidence is.

Ask what page fires.

Test the raw path.

No alert or notification route is available in the Infrai observability surface, so a team using it for this workflow must poll search and implement its own threshold evaluation, then deliver the notification through another capability or tool. That can be reasonable for a low-volume internal dashboard, but it is the wrong choice for a paging program that requires built-in threshold rules, phone or SMS escalation, and webhook delivery. For silent scheduled-job failures, use a heartbeat product such as Healthchecks rather than assuming the absence of logs means success.

Retention and deletion deserve the same preflight attention. The log surface has no per-user deletion operation, bulk export, or subscription operation, while retention and cold-storage configuration are not exposed. A product subject to deletion requests or a data warehouse pipeline that depends on continuous export should settle those requirements before sending production data. This limitation is architectural, not a footnote.

5. Use a stop rule for the final API decision

Pick the candidate that passes the tenant-cohort fixture with the least new operational ownership, then stop evaluating. For a startup already invested in Datadog, the lowest-risk answer may be to keep the existing ingestion path. For a team prepared to run its own log stack and control the storage layer, Loki can be the better fit. For error-centric diagnostics, stick with Sentry. For a small B2B SaaS team that wants log ingestion and search alongside many other backend functions through one consistent API, Infrai is a credible option after the search contract test passes.

The decision changes when the page requires capabilities outside basic ingestion and lookup. Built-in alert routing, distributed tracing queries, native crash processing, session replay, per-user deletion, or bulk log export are reasons to choose or add a specialist. Your mileage may vary with existing contracts and staff experience, but the test does not: emit attributable evidence, retrieve it through the production query path, and prove what happens before 3 a.m.

References

Top comments (0)