Easy log submission reduces integration work, but charging a logistics experiment's tenant cohorts requires evidence that survives duplicate delivery and provider changes. TL;DR: a simple JSON ingestion API is the most beginner-friendly starting point for centralized app logs; choose it behind a stable application contract, and check assignment, duplicate handling, and the completion denominator before using those logs for cost attribution. The capability contract should stay put when the vendor behind it changes, so switching providers doesn't require rewriting the application call sites.
Keep the receipts.
The distinction matters because a logging integration can accept every event and still leave the experiment comparison wrong. A successful submission establishes that something was accepted. It doesn't establish which tenant should bear the consumption, whether the same receipt was counted twice, or how many dispatch decisions actually completed.
What page fired, and what would the postmortem need?
Consider a hypothetical production exercise: a logistics application compares dispatch recommendations across 2 tenant cohorts, control and treatment, and an operator receives a page from the organization's existing incident process about a discrepancy in the experiment's consumption report. This is an illustrative scenario, not an account of an incident I handled. The useful postmortem question is whether the responder can reconstruct the allocation from preserved records without trusting the dashboard's total.
What page fired?
That question fixes the scope. A report about attribution needs assignment history, individual consumption receipts, and completed business operations over the reporting interval; a page about missing dispatch work would require different evidence. Neither the existence of a log ingestion API nor a healthy chart tells the responder that the appropriate detection mechanism has been configured. Define the page separately from the evidence used to investigate it.
In this exercise, one control dispatch has 2 separately charged attempts before completion. Its first receipt is delivered twice. Treatment has one attempt and one completion. Comparing event counts would mix transport behavior with experimental behavior, while counting attempts as completed dispatches would quietly change the question being answered. The invariant is simple: preserve each chargeable receipt once, preserve each completed operation once, and retain the tenant's assignment for the experiment interval.
Those are different identities.
The remaining argument follows that invariant. First choose an ingestion contract that a beginner can understand, then compare products against the operational work that remains, and finally reject ambiguous evidence before it becomes a cohort report.
What makes an API beginner-friendly for centralized app logs?
For centralized application logging, begin with JSON submission and an explicit application event contract. Keep the vendor mapping in one adapter. Tenant reference, experiment cohort, logical operation identity, and consumption receipt identity are application concepts; their names here are design choices, not assertions about any provider's accepted request fields. Inspect the chosen service's request schema before implementing that adapter, including how application context is represented.
Infrai offers one key, one bill, and a unified API: plain HTTP means no SDK required. Switching the backing vendor doesn't change the application code using the capability contract. Its JSON log ingestion is a reasonable starting point for a beginner centralizing application logs with minimal setup.
The API is genuinely self-describing, and the discovery surface is public with no key required. That lets the implementer inspect request and response schemas before writing the adapter. Consistent per-call cost, vendor, latency, and request metadata can also supply receipts for the experiment's reconciliation, provided the application preserves their association with its own tenant and operation identities.
That last association is application work. Account consumption doesn't become tenant attribution merely because both values can appear in a report.
The first integration check is therefore quite practical: can a new engineer identify the event being sent, the schema that accepts it, the identity used to detect a duplicate, and the evidence retained after submission? A small interface is helpful when those answers are clear. A short request copied without understanding its delivery semantics is merely short.
OpenTelemetry's logs concepts provide a useful vocabulary for separating a record's body, attributes, and resource context. Use that vocabulary to review the adapter, rather than assuming that every service will accept an identical envelope or that a common field name guarantees identical semantics.
Check 2: Which option fits the evidence the responder needs?
A fair comparison starts with the same dispatch experiment and asks what work the team must own. The following options have different integration models; these are selection boundaries, not measured performance rankings or claims of interchangeable payloads.
| Option | Relevant integration model | Boundary for the logistics experiment |
|---|---|---|
| Datadog Logs | Documented collection and log investigation workflows. | Evaluate how tenant and experiment context arrives in the investigation view, and who owns the join to consumption records. |
| Grafana Loki | A label-oriented logging model with explicit guidance on label cardinality. | Decide which dimensions belong in labels before using tenant or per-operation identifiers there; attribution still needs the underlying event identities. |
| Elastic Observability | Application log ingestion within the Elastic ecosystem. | Evaluate the ingestion and field-mapping work needed to keep cohort assignment consistent across producers. |
An existing deployment changes the decision. If the team already has a collection path and incident workflow in one of these systems, adding a small amount of application context may be easier to operate than introducing another destination. If it is starting with a bounded JSON logging requirement, a simple ingestion API offers a smaller initial integration surface. Neither choice removes the reconciliation work.
The trade-off is concentration: one vendor to trust and one outage surface for backend capabilities and their evidence. This is not a fit when the incident evidence must remain available independently of that backend; choose a separate destination such as Datadog Logs instead. That independence can justify the extra integration work.
Distrust the dashboard.
More precisely, distrust a dashboard whose aggregation can't be reproduced from the records it claims to summarize. The attractive chart is downstream of decisions about duplicate receipts, assignment changes, shared consumption, and completion counts. A richer investigation interface can help inspect those decisions, but it cannot retroactively create an identifier that the producer never emitted.
Paging, trace investigation, crash analysis, retention, deletion, and export requirements deserve their own acceptance criteria when they are part of the job. JSON ingestion alone doesn't establish any of them. For this experiment, select the product whose documented contract and operational workflow meet the actual requirements, then make the allocation policy explicit rather than hiding it in a query.
Check 3: Can the report reject ambiguous receipts?
The preventative path below is a complete Go 1.20 program using only the standard library. Its first step makes an authenticated log-search read with no filter parameters and prints the returned JSON for inspection; its second step demonstrates the allocation invariant on synthetic local records. These steps are deliberately distinct: the service response has not been mapped to accounting receipts. Every Record field belongs to the example application, and no undocumented API payload is implied.
Save it as main.go. Configure INFRAI_BASE_URL to the documented HTTPS API base including the v1 prefix, and INFRAI_API_KEY to your credential. The read uses an explicit method, checks status, and retries rate limits with exponential backoff while honoring Retry-After. It performs no writes. Treat its returned log data as operational evidence, with the same access controls as the source system.
All values are synthetic. The integer units represent an invented internal accounting unit, not currency, a vendor rate, or a performance measurement. The program starts with 4 delivered records representing 3 unique consumption receipts and 2 completed dispatch operations. An exact duplicate receipt contributes no additional units; a new attempt with a new receipt does. A contradictory duplicate stops the report.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func inspectLogs() error {
base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
key := os.Getenv("INFRAI_API_KEY")
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" ||
u.Path != "/v1" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return fmt.Errorf("set INFRAI_BASE_URL to the documented HTTPS API base")
}
if key == "" {
return fmt.Errorf("missing INFRAI_API_KEY")
}
client := &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequest(http.MethodGet, base+"/logs/search", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
delay := time.Second * time.Duration(1<<attempt)
retry := resp.Header.Get("Retry-After")
if seconds, err := strconv.ParseInt(retry, 10, 32); err == nil && seconds >= 0 {
delay = maxDelay(delay, time.Duration(seconds)*time.Second)
} else if deadline, err := http.ParseTime(retry); err == nil {
delay = maxDelay(delay, time.Until(deadline))
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("log search status %d: %s", resp.StatusCode, body)
}
if len(body) > 1<<20 {
return fmt.Errorf("log response exceeds this example's 1 MiB read budget")
}
if !json.Valid(body) {
return fmt.Errorf("log response is not valid JSON")
}
_, err = fmt.Fprintf(os.Stdout, "%s\n", body)
return err
}
return fmt.Errorf("log read retry budget exhausted")
}
func maxDelay(a, b time.Duration) time.Duration {
if b > a {
return b
}
return a
}
type Record struct {
Tenant, Cohort, Operation, Receipt string
Units int64
Completed bool
}
type Summary struct {
Units int64
Completed int
}
func summarize(records []Record, assignment map[string]string) (map[string]Summary, error) {
receipts := make(map[[2]string]Record)
operations := make(map[[2]string]bool)
totals := make(map[string]Summary)
for _, r := range records {
cohort, ok := assignment[r.Tenant]
if !ok || cohort == "" || r.Cohort != cohort {
return nil, fmt.Errorf("unresolved assignment for tenant %q", r.Tenant)
}
if r.Operation == "" || r.Receipt == "" || r.Units < 0 {
return nil, fmt.Errorf("invalid receipt for tenant %q", r.Tenant)
}
receipt := [2]string{r.Tenant, r.Receipt}
if previous, exists := receipts[receipt]; exists {
if previous != r {
return nil, fmt.Errorf("conflicting receipt %q", r.Receipt)
}
continue
}
receipts[receipt] = r
total := totals[cohort]
const maxInt64 = int64(1<<63 - 1)
if total.Units > maxInt64-r.Units {
return nil, fmt.Errorf("accounting units overflow for %q", cohort)
}
total.Units += r.Units
operation := [2]string{r.Tenant, r.Operation}
if r.Completed && !operations[operation] {
operations[operation] = true
total.Completed++
}
totals[cohort] = total
}
return totals, nil
}
func run() error {
if err := inspectLogs(); err != nil {
return err
}
assignment := map[string]string{"tenant-a": "control", "tenant-b": "treatment"}
records := []Record{
{"tenant-a", "control", "dispatch-1", "receipt-1", 3, false},
{"tenant-a", "control", "dispatch-1", "receipt-1", 3, false},
{"tenant-a", "control", "dispatch-1", "receipt-2", 2, true},
{"tenant-b", "treatment", "dispatch-2", "receipt-3", 4, true},
}
totals, err := summarize(records, assignment)
if err != nil {
return err
}
for _, cohort := range []string{"control", "treatment"} {
total := totals[cohort]
if total.Completed == 0 {
return fmt.Errorf("no completed dispatches for %q", cohort)
}
fmt.Printf("%s: %d units / %d completed dispatches\n",
cohort, total.Units, total.Completed)
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Control yields 5 units for one completed dispatch; treatment yields 4 for one. Those are arithmetic results from the example, not evidence that treatment performs better in production. The duplicate first receipt contributes nothing extra. Removing the second receipt would change both the consumed units and the completion evidence, which is exactly why a count of delivered log messages is an unsafe substitute for a business denominator.
The rejection behavior matters more than the totals. If an assignment is missing or a receipt reappears with different contents, the report stops rather than silently assigning consumption to whichever cohort happens to be convenient. In this small model, a receipt is an immutable accounting observation; a later completion update must have its own event representation instead of mutating that receipt. Persist receipt identity across reporting runs if the real system needs duplicate protection across runs. The in-memory maps only cover this invocation.
Where does this accounting model stop being useful?
This example assumes one fixed assignment per tenant for a closed reporting interval, comparable workloads, complete receipt input, and directly attributable consumption. It doesn't allocate shared infrastructure costs or establish statistical significance. A mid-interval reassignment requires assignment history and event-time rules; missing receipts require reconciliation before any cohort comparison is trustworthy. Don't stretch the toy model to answer those questions.
For an initial centralized logging integration, simple JSON ingestion remains a sensible answer. For a postmortem about who consumed what, demand reproducible identities and a declared denominator as well. The decision rule is to choose the easiest documented ingestion contract that preserves the evidence your experiment needs, then evaluate richer collection or investigation tooling when the workload and incident requirements justify it.
Sources
References for the logging vocabulary and comparison criteria:
Top comments (0)