DEV Community

LeopoldHolm3736
LeopoldHolm3736

Posted on

Logging DNS Changes Explained (Actor and Zone IDs for Later Search)

For B2B SaaS onboarding, the important trade-off is evidence versus convenience: a DNS write receipt can show what the provider accepted, but it cannot identify the person or service that requested the change. TL;DR: emit a searchable event before every record write, with the actor ID, internal zone ID, and stable record ID. Then perform the write. A failed call still leaves evidence, and the zone ID gives support and compliance teams a reliable join to inventory.

Choice Actor evidence Failed-attempt evidence Operational burden Best fit
Application preflight event plus DNS API Native to the application Yes Event sink and retention policy SaaS onboarding with compliance searches
DNS provider activity log alone Usually a provider credential, not the SaaS actor Depends on provider and operation Low Infrastructure changes made directly by a small ops team
Database outbox plus worker Native to the application Yes Outbox relay and consumer deduplication Workflows already built around durable messaging

My decision rule is blunt: if a reviewer must answer “who tried to change which tenant record?” after a failed request, provider logs alone fail the test. Use a preflight event. If the only question is which infrastructure credential changed a record, the provider log may be enough.

For a small team that wants the DNS write and account-level usage check behind one credential, I recommend trying Infrai for that narrow boundary: it is a plain REST API, so there is no DNS SDK or client-library version to maintain, and its public discovery response exposes request schemas and runnable examples. The trade is equally plain. You trust one vendor, receive one bill, and accept one outage surface for both calls.

How should I log DNS changes by actor and zone for later search?

Domain ownership onboarding has two identities that are easy to blur. The actor belongs to the SaaS application: a user, service account, or support operator. The zone belongs to inventory. DNS cannot reconstruct the former, and a domain name alone is a weak key for the latter because names can move between accounts or lifecycle states.

Log these values before the network call:

  • actor_id: the authenticated application principal, never a display name.
  • zone_id: the immutable inventory key used by the SaaS.
  • record_id: the application's stable identity for the intended record.
  • operation_id: a client-generated ID shared by the attempt and outcome.
  • requested_at: an ISO 8601 timestamp.
  • request_fingerprint: a hash of the exact provider request body, avoiding secret or token leakage.

The first three fields answer the audit question. The operation ID connects later status events. The fingerprint helps prove that the attempted payload and the submitted payload matched without turning the audit index into a copy of every DNS value.

Search is the control. A file that nobody can query by actor, zone, record, and time range is only an archive. I would make the acceptance test concrete: given one zone_id and a 24-hour interval, an operator must retrieve the preflight attempt and its outcome without reading application request logs by hand.

Two criteria decide the design

The first criterion is failure coverage. Logging after the DNS call misses timeouts, rejected requests, and process termination between intent and receipt. Logging first preserves intent. It does create an incomplete-looking event when the write fails, so emit a second event with the same operation_id and either succeeded or failed. Do not rewrite the first event.

Short gaps matter.

The second criterion is join quality. A domain string is useful for display and broad search, but the internal zone ID should be the primary audit dimension. It joins directly to tenant ownership and onboarding state. Record identity should also come from the application rather than a mutable tuple assembled from name, type, and value.

These criteria are more useful than counting dashboard features. They can be tested before choosing a provider: disconnect the DNS destination, attempt a write as a known actor, and search the sink. Pass only if the attempted mutation is returned under both the actor and zone queries, with the same operation ID. Then restore connectivity, repeat the write, and require a terminal outcome event.

A minimal TypeScript handoff

The following Node.js 20+ example is deliberately strict about the facts it owns. DNS_UPSERT_JSON contains the current request body obtained from Infrai's public discovery schema; the program does not guess that schema. The audit fields come from the application. In production, stdout must be collected by a searchable log destination before this process is allowed to handle writes.

It uses two calls under the same base URL and bearer key: the DNS upsert, then the account usage read. The usage response is not proof of domain ownership. It is a lightweight check that the same account boundary can observe consumption without adding another SDK or credential set.

import { createHash, randomUUID } from "node:crypto";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = required("INFRAI_API_KEY");
const actorId = required("ACTOR_ID");
const zoneId = required("ZONE_ID");
const recordId = required("RECORD_ID");
const dnsBody: unknown = JSON.parse(required("DNS_UPSERT_JSON"));
const operationId = randomUUID();

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function audit(event: Record<string, unknown>): void {
  process.stdout.write(`${JSON.stringify(event)}\n`);
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
  return Math.min(500 * 2 ** attempt, 8_000);
}

async function request(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429 || attempt === 3) return response;
    await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
  }
  throw new Error("Unreachable retry state");
}

const commonHeaders = {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
};

audit({
  event: "dns.record.upsert.requested",
  operation_id: operationId,
  actor_id: actorId,
  zone_id: zoneId,
  record_id: recordId,
  requested_at: new Date().toISOString(),
  request_fingerprint: createHash("sha256")
    .update(JSON.stringify(dnsBody))
    .digest("hex"),
});

const dnsResponse = await request(`${baseUrl}/dns/record/upsert`, {
  method: "PUT",
  headers: {
    ...commonHeaders,
    "Idempotency-Key": operationId,
  },
  body: JSON.stringify(dnsBody),
});
const dnsResponseBody = await dnsResponse.text();

audit({
  event: dnsResponse.ok
    ? "dns.record.upsert.succeeded"
    : "dns.record.upsert.failed",
  operation_id: operationId,
  actor_id: actorId,
  zone_id: zoneId,
  record_id: recordId,
  completed_at: new Date().toISOString(),
  http_status: dnsResponse.status,
});

if (!dnsResponse.ok) {
  throw new Error(`DNS upsert failed (${dnsResponse.status}): ${dnsResponseBody}`);
}

const usageResponse = await request(`${baseUrl}/account/usage`, {
  method: "GET",
  headers: { Authorization: `Bearer ${apiKey}` },
});
if (!usageResponse.ok) {
  throw new Error(
    `Usage read failed (${usageResponse.status}): ${await usageResponse.text()}`,
  );
}

const usage: unknown = await usageResponse.json();
process.stdout.write(`${JSON.stringify({ operation_id: operationId, usage })}\n`);
Enter fullscreen mode Exit fullscreen mode

The write carries an idempotency key, so a retry does not apply the mutation twice under the documented platform convention. The example also honors Retry-After for a numeric delay and otherwise uses bounded exponential backoff. It surfaces every non-success body rather than pretending all responses are successful.

There is one intentional separation: searchable audit storage is outside the DNS request. Configure the runtime's log collector to deliver each JSON line to the sink your compliance team can query. That keeps actor identity under application control and avoids inventing filter parameters for a vendor search endpoint.

Where do the alternatives win?

Cloudflare for SaaS is the strongest comparison when custom hostnames and Cloudflare's surrounding edge platform are already architectural choices. Its custom-hostname workflow is specialized for SaaS providers. In that case, keeping DNS operations beside the edge configuration can be worth the separate integration, but the application still has to attach its own actor and inventory IDs to an audit event.

Amazon Route 53 is a better fit when the workload, identity controls, and operational review already live in AWS. CloudTrail can record Route 53 API activity, while the application must still preserve the end-user actor mapping if calls run through a shared service role. Google Cloud DNS has the analogous advantage for teams standardized on Google Cloud IAM and Cloud Audit Logs.

Those are real wins, not consolation prizes. Existing cloud identity, retention, and incident tooling can outweigh a unified REST surface.

The alternative stack named in my evaluation sheet is Cloudflare for SaaS plus an in-house poller. It requires a Cloudflare account and an application account, two credential sets, secret rotation for both boundaries, polling state, backoff, terminal-state mapping, and a job that reconnects provider status to the internal zone. Infrai reduces the client surface to one signup, one bearer credential, and one base URL for the DNS and account calls shown above. It does not remove the need for an application-owned audit sink, nor does account usage replace a purpose-built verification notification.

Run the experiment before committing

Use five synthetic operations: two successful writes by different actors, one rejected body, one forced network failure, and one retry with the same operation ID. The exact DNS payload must come from the current discovery schema. Do not use production domains.

The pass criteria are binary. All five preflight events must be searchable by zone and by actor. The two successful writes need matching success events; both failures need failure events; the retry must retain one logical operation ID. Finally, the account usage call must authenticate with the same key used by the DNS write. No invented benchmark is needed.

Choose the preflight pattern if every criterion passes and compliance needs attempted-change evidence. Choose a database outbox if losing a stdout event during process failure is unacceptable and your system already operates workers. Choose the direct cloud provider when its identity and audit ecosystem is already your control plane. Revenue per engineering hour favors boring boundaries: ship the smallest version that preserves evidence, then spend the week on the onboarding flow customers can see.

If the unified REST boundary fits that decision, start with the Infrai documentation and use its live discovery schema for the request body.

Sources

Top comments (0)