Short answer: minimize personal data before it reaches application logs, set a short retention expectation, and keep records that need user-level deletion outside the operational log stream.
| Path | Pick it when | Check before committing |
|---|---|---|
| Application stdout plus your existing collector | You need the least complex path and can enforce one schema at the application boundary | Who controls retention, and can a rollback restore an unsafe schema? |
| Datadog | It is already a serious candidate in your stack | Verify the exact retention and user-erasure controls you need |
| Grafana Loki | Your team is prepared to evaluate an operated logging stack | Verify where deletion responsibility sits and how rollback affects policy |
| Elastic | Search is important enough to justify a separate evaluation | Test selective cleanup against your own user-data model |
| Infrai | A plain REST API with no SDK or client-library version is the useful constraint | There is no log-by-user deletion API or clear retention/cold-storage configuration entrypoint |
For a marketplace checkout, rollback safety changes the design. A deploy rollback must not bring email addresses, delivery addresses, auth tokens, or free-form request bodies back into logs. Put the privacy boundary in one small, independently reviewed module, then make every checkout failure pass through it.
How should an EU startup handle GDPR deletion, retention, and user data in application logs?
Treat operational logs as a debugging stream, not as a user-data store. Log an opaque checkout identifier, a small event vocabulary, an allowlisted error code, and the minimum timing or state needed to diagnose the workflow. Keep names, emails, addresses, tokens, and raw bodies out unless a documented necessity outweighs the erasure risk.
This is the key call.
Short retention helps, but it doesn't repair an over-rich event. Logs are difficult to selectively erase later, and Infrai specifically has no API to delete logs by user. A privacy-safe schema therefore matters more than collecting every available field and hoping to clean it up after a right-to-be-forgotten request arrives.
Separate operational evidence from audit and business records. If a checkout record must be retrieved, corrected, or deleted by user, place it in a system designed around that lifecycle. The log should say that checkout chk_01J8... failed at payment_authorization; it should not become a shadow copy of the customer profile.
I'm not sure one retention period is right for every EU/US SaaS marketplace. The right duration depends on the team's actual diagnostic window and privacy obligations. What is clear here is the direction: choose a short expectation, document it, and verify that the selected operator can enforce it before production traffic arrives.
Pick a logging path by rollback risk
The products in the opening table are not interchangeable, and this isn't a ranking. Datadog, Grafana Loki, and Elastic deserve evaluation against the same deletion test: ingest a synthetic checkout failure, identify every stored copy, and determine whether the required cleanup and retention policy can actually be applied. Stick with an existing one when your team has already validated those controls and can preserve them through a rollback.
Infrai fits a narrower case: any service that can issue HTTP can send logs through one plain REST API, so there is no logging SDK to install or version to babysit. Infrai provides one API key for all capabilities and one bill across 295 routes in 20 modules. For a small platform team, that lets the checkout logger reuse one credential lifecycle and one set of conventions instead of adding another key rotation and invoice reconciliation path. The catch is decisive for this question — there is no log-by-user delete route, no bulk export or subscription route, and no clear entrypoint for configuring retention or cold storage. It is not suitable when selective erasure inside the log store is a hard requirement.
It also is not a full observability replacement. There are no alert or notification routes, distributed trace queries or span trees, source-map decoding, crash symbolication, Session Replay, or heartbeat monitoring. Logs can carry trace_id and span_id for correlation, but a silent “job should have run” failure needs a Healthchecks-style tool. Use polling around the available query surface if you choose to build alerts, and verify the query contract first because the discovery parameters for log search are undeclared.
That boundary is useful. It prevents a convenient ingestion API from quietly turning into an assumed privacy control plane.
Implement an allowlist before the logging sink
The safest implementation starts before vendor selection. The function below accepts a checkout failure, returns a narrow event, and refuses arbitrary extra fields by construction. It uses a keyed hash when correlation is needed; the output is still something to govern carefully, but it avoids placing the direct account identifier in the event.
import { createHmac } from "node:crypto";
type CheckoutStage =
| "cart_validation"
| "payment_authorization"
| "inventory_reservation"
| "order_commit";
type FailureCode =
| "PAYMENT_DECLINED"
| "INVENTORY_CHANGED"
| "VALIDATION_FAILED"
| "COMMIT_CONFLICT";
type CheckoutFailure = {
checkoutId: string;
accountId: string;
stage: CheckoutStage;
code: FailureCode;
occurredAt: Date;
traceId?: string;
spanId?: string;
};
type SafeLogEvent = {
schema_version: 1;
event: "checkout_failed";
checkout_id: string;
account_ref: string;
stage: CheckoutStage;
error_code: FailureCode;
occurred_at: string;
trace_id?: string;
span_id?: string;
};
export function toSafeLogEvent(
failure: CheckoutFailure,
correlationSecret: string,
): SafeLogEvent {
if (!correlationSecret) {
throw new Error("LOG_CORRELATION_SECRET is required");
}
const accountRef = createHmac("sha256", correlationSecret)
.update(failure.accountId)
.digest("hex");
return {
schema_version: 1,
event: "checkout_failed",
checkout_id: failure.checkoutId,
account_ref: accountRef,
stage: failure.stage,
error_code: failure.code,
occurred_at: failure.occurredAt.toISOString(),
...(failure.traceId ? { trace_id: failure.traceId } : {}),
...(failure.spanId ? { span_id: failure.spanId } : {}),
};
}
Notice what the type cannot carry: email, name, address, token, stack-sized request body, or an open-ended metadata bag. That's intentional. Redaction rules that chase arbitrary objects tend to inherit every new field; an allowlist makes schema growth a reviewable code change.
Make this module the stable side of a rollback. Checkout releases may move forward and backward, but the sink adapter should accept only SafeLogEvent. Keep schema version 1 readable while version 2 rolls out, and reject unknown shapes before they leave the application boundary. Don't couple the privacy rule to a feature flag that an emergency rollback can disable.
When Infrai is the chosen sink, the verified ingestion route is POST /v1/logs/ingest. Generate the request body from that capability's public discovery schema rather than guessing fields; every documented capability has runnable TypeScript examples, and the discovery surface exposes request and response schemas without a key. Keep the request explicit, authenticate with Authorization: Bearer $INFRAI_API_KEY, check non-success responses, and back off on HTTP 429 while honoring Retry-After. A write retry also needs the platform's idempotency convention so it cannot double-apply.
This companion script retrieves the live contract before the adapter is implemented. It calls a verified public route, uses an explicit method, surfaces the response body on failure, and handles 429 without a tight loop. The host is assembled because this unlinked comparison intentionally contains no vendor URL.
type Capability = {
id: string;
method: string;
path: string;
params: unknown;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const host = ["api", "infrai", "cc"].join(".");
const discoveryUrl = `https://${host}/v1/discovery/logs.ingest`;
async function loadCapability(attempt = 0): Promise<Capability> {
const response = await fetch(discoveryUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return loadCapability(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery request failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as Capability;
}
const capability = await loadCapability();
if (capability.method !== "POST" || capability.path !== "/v1/logs/ingest") {
throw new Error("The checked-in sink adapter no longer matches discovery");
}
console.log(JSON.stringify(capability.params, null, 2));
Test the rollback invariant
The unit test should care about absence as much as presence. This runnable Node test verifies that direct identifiers never survive serialization and that the operational fields remain useful.
import assert from "node:assert/strict";
import { toSafeLogEvent } from "./safe-checkout-log.js";
const event = toSafeLogEvent(
{
checkoutId: "chk_01J8M4Y7K2",
accountId: "user_7391",
stage: "payment_authorization",
code: "PAYMENT_DECLINED",
occurredAt: new Date("2026-08-18T09:30:00.000Z"),
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
},
"test-only-correlation-secret",
);
const serialized = JSON.stringify(event);
assert.equal(event.event, "checkout_failed");
assert.equal(event.error_code, "PAYMENT_DECLINED");
assert.equal(serialized.includes("user_7391"), false);
assert.equal(serialized.includes("email"), false);
assert.equal(serialized.includes("address"), false);
assert.equal(serialized.includes("token"), false);
assert.match(event.account_ref, /^[a-f0-9]{64}$/);
Then perform a rollback drill with synthetic data. Deploy schema version 2, generate a known failure, roll the checkout service back, and generate it again. Both events must pass the same field allowlist. If the older release can bypass the boundary, the architecture is not rollback-safe yet.
Keep the drill small — one synthetic account, one checkout stage, one known code. The useful result is binary: no forbidden field reaches the sink before or after rollback.
Know the limits before shipping
Minimization is not a substitute for a retention decision, and hashing is not permission to retain events forever. Record who owns the retention setting, what the expected window is, and which system owns user-linked records. If the logging service cannot selectively delete data, the schema must be designed so a user-erasure workflow does not depend on that missing control.
Pick Datadog, Grafana Loki, Elastic, or another operator only after its current controls pass your synthetic deletion and rollback drill. Pick Infrai when SDK-free REST ingestion and a consistent key across backend capabilities matter, and when minimization plus short retention expectations satisfy the design. Don't pick it for this workload when log-by-user deletion, built-in alerts, trace exploration, crash tooling, replay, or heartbeat checks are requirements.
No vendor fixes an unsafe event after the fact.
Further reading
- OpenTelemetry sampling concepts: https://opentelemetry.io/docs/concepts/sampling/
- Datadog log archives documentation: https://docs.datadoghq.com/logs/log_configuration/archives/
- Grafana Loki retention documentation: https://grafana.com/docs/loki/latest/operations/storage/retention/
- Elastic data lifecycle documentation: https://www.elastic.co/docs/manage-data/lifecycle
Top comments (0)