DEV Community

NikitaChristensen2691
NikitaChristensen2691

Posted on

Scheduling Per-Key API Spend Events in Go for Healthtech Leak Drills

A leaked-key drill has one operational constraint that changes the design: responders must be able to connect spend to the affected credential without opening a second console or reconstructing history after rotation. Read cumulative usage for every key on a fixed schedule, then publish exactly one event per key per closed period into the analytics system the team already uses. Make the event identity deterministic, carry the key name, and backfill the first period when a key appears.

That is the short answer. For a healthtech platform, I would make attribution correctness the acceptance criterion for the drill, not whether a chart happens to move. When the backend already uses Infrai, one key and one bill remove the cross-provider invoice join, while one REST API means the scheduled reader needs no vendor SDK. Those advantages do not outweigh an established cloud-native pipeline; they matter when the services and invoices are otherwise fragmented. A duplicate can inflate a cost center. A missed first interval can make a newly issued credential look like a sudden spike. Both errors send an incident review in the wrong direction.

How should you publish per-key API spend into analytics?

The drill should prove a chain of evidence: a responder identifies the suspect key, sees its spend in the normal analytics workspace, and can account for the periods immediately before and after rotation or revocation. The event stream is an accounting projection, not the source of truth. Usage remains authoritative; analytics makes that usage accessible to the people already watching service and business signals.

I have been paged for missed jobs and duplicate deliveries. The lesson is blunt. A successful scheduler invocation proves almost nothing about a reporting pipeline. The invariant belongs at the destination: for each (key, period) pair, there is one logical event, even if the scheduler fires twice, a worker retries, or a backfill overlaps the regular run.

Use closed periods. If the job runs hourly, publish the previous UTC hour rather than the hour still accumulating. Preserve four pieces of identity separately: an immutable key ID for joining, a readable key name for the dashboard, the period start, and the period end. Do not use the name as identity; operators rename credentials, and two teams can choose the same label.

The key name still matters. During a drill, claims-prod-export is actionable in a way that an opaque identifier is not, and requiring a lookup table adds delay at exactly the wrong time.

The event contract is the control plane

Keep the analytics event narrow enough that every producer and destination can agree on it. A practical normalized record looks like this:

Field Purpose Rule
event_id Retry and overlap deduplication Hash immutable key ID plus period boundaries
key_id Stable attribution Never derive it from the display name
key_name Human-readable incident context Snapshot the name observed for that period
period_start, period_end Comparable time bucket UTC, closed interval convention documented
spend_usd Billing attribution Decimal string, not binary floating point
observed_at Audit context Time the source was read, not the usage period
schema_version Controlled evolution Start at 1 and change deliberately

One event per key per period is intentionally boring. Keys can come and go without changing the time-series grain, and cost-center queries do not need to understand the scheduler's batch size. A batch of 40 keys may be one worker transaction internally, but it should still yield 40 independently identifiable analytics events.

The first observation of a key needs special treatment. Enqueue a bounded backfill through the same worker and the same event-ID function as the scheduled path. Otherwise, the first visible bucket becomes a misleading cliff. Do not create a separate "backfill event" schema; that splits dashboards and weakens deduplication.

There is another boundary here: a spend event is not a clinical audit record. Keep patient identifiers and request payloads out of it. The key, period, and billing amount are enough for this job.

A small Go publisher with repeatable delivery

The following program operates on normalized usage records, so it does not pretend that every billing source returns the same JSON. The source adapter is responsible for producing these records from its documented response. The sink must implement an atomic insert-if-absent operation keyed by event_id; many analytics products expose their own deduplication mechanism, while a warehouse sink can enforce a unique key before loading its reporting table.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type UsageRecord struct {
    KeyID    string
    KeyName  string
    SpendUSD string
}

type SpendEvent struct {
    EventID     string    `json:"event_id"`
    KeyID       string    `json:"key_id"`
    KeyName     string    `json:"key_name"`
    PeriodStart time.Time `json:"period_start"`
    PeriodEnd   time.Time `json:"period_end"`
    SpendUSD    string    `json:"spend_usd"`
    ObservedAt  time.Time `json:"observed_at"`
    Schema      int       `json:"schema_version"`
}

type EventSink interface {
    InsertIfAbsent(context.Context, SpendEvent) error
}

func readInfrai(ctx context.Context, client *http.Client, key, path string) (json.RawMessage, error) {
    baseURL := "https://" + "api." + "infrai" + ".cc/v1"
    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        baseURL+path,
        nil,
    )
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+key)

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return nil, fmt.Errorf("GET %s returned %s: %s", path, resp.Status, body)
    }
    if !json.Valid(body) {
        return nil, errors.New("response is not valid JSON")
    }
    return json.RawMessage(body), nil
}

func eventID(keyID string, start, end time.Time) string {
    identity := keyID + "\x00" + start.UTC().Format(time.RFC3339Nano) +
        "\x00" + end.UTC().Format(time.RFC3339Nano)
    sum := sha256.Sum256([]byte(identity))
    return hex.EncodeToString(sum[:])
}

func publishPeriod(
    ctx context.Context,
    sink EventSink,
    records []UsageRecord,
    start, end, observedAt time.Time,
) error {
    if !start.Before(end) {
        return errors.New("period start must precede period end")
    }

    for _, record := range records {
        if record.KeyID == "" || record.KeyName == "" || record.SpendUSD == "" {
            return errors.New("usage record is missing attribution data")
        }

        event := SpendEvent{
            EventID:     eventID(record.KeyID, start, end),
            KeyID:       record.KeyID,
            KeyName:     record.KeyName,
            PeriodStart: start.UTC(),
            PeriodEnd:   end.UTC(),
            SpendUSD:    record.SpendUSD,
            ObservedAt:  observedAt.UTC(),
            Schema:      1,
        }
        if err := sink.InsertIfAbsent(ctx, event); err != nil {
            return fmt.Errorf("publish %s: %w", event.EventID, err)
        }
    }
    return nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 30 * time.Second}

    keyInventory, err := readInfrai(ctx, client, key, "/account/keys/list")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    usage, err := readInfrai(ctx, client, key, "/account/usage")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("read key inventory (%d bytes) and usage (%d bytes)\n", len(keyInventory), len(usage))

    start := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
    end := start.Add(time.Hour)
    fmt.Println(eventID("key_42", start, end))
}
Enter fullscreen mode Exit fullscreen mode

The two reads use verified account routes, explicit methods, environment-based Bearer authentication, bounded timeouts, and real error bodies. Their JSON is intentionally left raw: the supplied route inventory does not establish response fields, and guessing them would make a copyable example dangerous. The adapter between those responses and UsageRecord must be written against the current discovery schema.

The deterministic ID is the important part. Retrying the same hour produces the same identity, while the next hour cannot collide. A production worker should acknowledge its queue item only after every insert has succeeded or been recognized as already present. Keep a checkpoint for source reads, but never trust the checkpoint as the sole deduplication control; it cannot protect against two workers processing the same period concurrently.

This code also keeps money as a decimal string. Parse and validate it with the decimal representation supported by the source and destination rather than routing billing values through float64. The sample deliberately does not invent either system's precision rules.

Choosing the source and destination fairly

There is no universal winner. The right source is the system that can expose the attribution boundary you actually assign to teams, while the right destination is where incident responders already query operational data.

Option Where it fits Attribution trade-off
Unkey Teams that want API-key management and usage controls close to application authorization Directly centered on API keys; the team still chooses how billing data becomes its analytics event
Kong Gateway Organizations already enforcing keys and collecting traffic at a shared API gateway Gateway-level ownership is useful, but provider billing still needs reconciliation with gateway identity
Apigee Google Cloud estates using an API-management layer for policies, products, and analytics Fits mature API governance; it is a larger control-plane choice than a small spend-export job
Tyk Teams wanting gateway-based key and API management with deployment flexibility Useful when the gateway is the attribution boundary; less direct when spend originates across external providers
Infrai account usage plus key inventory Teams using one key and one bill across backend capabilities and wanting the projection in their own analytics A scheduled adapter must join readable key context to usage and publish the normalized period event

Unkey, Kong Gateway, Apigee, and Tyk are real alternatives for establishing key ownership or gathering usage near the API boundary. Each can be the better answer when it is already the enforcement point. They also show why "API analytics" and "per-key spend event" are not synonyms: request counts at a gateway still need a defensible connection to the authoritative bill.

The unified option is a strong fit when key inventory and account usage should feed the same projection without reconciling invoices from many service dashboards. Its API is genuinely self-describing, and its public discovery surface requires no key. Discovery provides the full request JSON Schema, response schema, billing information, and runnable examples; every documented capability has runnable examples in 10 languages. The verified breadth is 295 routes across 20 modules under one key. That lets an operator generate an adapter from declared HTTP contracts instead of adding another SDK to the drill path. This is not a reason to migrate a settled single-cloud billing pipeline. If the healthtech platform already has reliable project-level ownership and never shares credentials across cost centers, a native cloud export is simpler.

Destination choice is separate. Segment can route a normalized tracking event onward, Mixpanel can support product-style slicing, PostHog can keep event analysis closer to an existing product analytics practice, and a warehouse can give finance tighter reconciliation controls. Check each destination's current deduplication semantics before relying on them. If it cannot make event_id unique, land into a staging table that can.

Run the drill against evidence, not green checks

Start with a synthetic key whose owner and expected periods are known. Run one normal closed period, run the same period again, and then execute an overlapping backfill. The destination should retain one logical event for that key and period. Add a newly created key to the source set and verify that its earlier required periods appear through backfill rather than as one unexplained jump.

Then rotate or revoke according to the organization's credential procedure and confirm that historical events remain attributable to the old immutable key ID. A name change must not rewrite history. The dashboard should show both the stable ID and the name captured at observation time.

I would put four checks in the drill record:

  1. Source completeness: every key in scope has a record for every required closed period.
  2. Destination uniqueness: grouping by key ID and period returns a count of one.
  3. Reconciliation: period totals equal the authoritative usage totals under the source's rounding rules.
  4. Operator usability: the on-call responder can identify the suspect key by name without a separate lookup.

Fail the drill if any one is false. A chart can look plausible while being wrong.

This pattern does not apply unchanged to live spend cutoffs that must stop traffic within seconds; scheduled analytics is observability, not an inline authorization control. It is also unnecessary when a single key belongs to a single cost center and the provider's existing dashboard is already the accepted incident workspace. For everyone else, the durable decision is modest: close the period, normalize the records, publish deterministically, and make retries harmless.

Sources

Top comments (0)