Attribute each model call to its tutoring run before posting the error; otherwise, the failure record can explain what broke while leaving the cost of the failed lesson invisible. TL;DR: preserve the original exception, attach a bounded run identifier and per-call evidence, and verify delivery separately from the lesson outcome. Keep accounting evidence when error delivery is disabled.

The implementation question often starts with a Node.js catch block and an HTTP client such as axios. The useful contract is an explicit POST with message, stack, environment, and context. This guide demonstrates that capture-and-post boundary in Go, using an edtech agent loop whose operational question is which calls a failed lesson paid for.

What page fired, and which tutoring run paid for it?

Start the postmortem with the page, its triggering condition, and the affected run identifiers. An error event doesn't establish that someone was paged, that a learner was affected, or that an expensive call caused the failure. Those are separate pieces of evidence. A dashboard that makes error counts look calm offers little comfort if retries keep consuming model calls after the lesson has failed, especially when the only accounting record is attached to the final exception and omits earlier successful work.

Count the successful call.

Consider a synthetic tutoring run with 2 calls: one proposes a hint, and the next checks the answer. The example labels their costs as unknown rather than inventing charges. In production, preserve available cost metadata at each call boundary, together with the step, attempt, elapsed duration, and provider request identifier. A missing cost is unknown, not zero. Keep the authoritative ledger in normal application persistence; an error event carries only a small diagnostic snapshot.

Use opaque identifiers. Student names, answers, prompts, and access tokens don't belong in this diagnostic snapshot. Restricting the context makes investigation less convenient, but a support engineer can follow the run identifier through authorized application records without copying a learner's lesson into every downstream error system.

How should capturing and posting errors to an error tracking API preserve tutoring costs?

Save this program as capture.go and run it with go run capture.go. It uses the standard library and sends traffic only to a local test receiver. That receiver deliberately returns one rate-limit response before accepting the event. Its empty success response is a fixture, not a claim about a hosted API's response envelope.

The context keys are application-owned example data. The stack is collected at capture time, so a real application should preserve frames closer to the original failure when later wrapping or asynchronous delivery would lose them. A Node.js implementation should preserve the original error's message and stack before serializing its payload.

package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "runtime/debug" "strconv" "sync" "time" ) type Event struct { Message string `json:"message"` Stack string `json:"stack"` Environment string `json:"environment"` Context map[string]any `json:"context"` } func retryDelay(value string, fallback time.Duration) time.Duration { if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 { return time.Duration(seconds) * time.Second } if when, err := http.ParseTime(value); err == nil { if delay := time.Until(when); delay > 0 { return delay } return 0 } return fallback } func post(client *http.Client, target, key, eventID string, body []byte) error { deadline := time.Now().Add(3 * time.Second) for attempt := 0; attempt < 2; attempt++ { req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", eventID) if key != "" { req.Header.Set("Authorization", "Bearer "+key) } response, err := client.Do(req) if err != nil { return fmt.Errorf("capture delivery: %w", err) } data, readErr := io.ReadAll(io.LimitReader(response.Body, 4096)) response.Body.Close() if readErr != nil { return readErr } if response.StatusCode >= 200 && response.StatusCode < 300 { return nil } if response.StatusCode != http.StatusTooManyRequests || attempt == 1 { return fmt.Errorf("capture status %d: %s", response.StatusCode, data) } delay := retryDelay(response.Header.Get("Retry-After"), time.Duration(1<<attempt)*100*time.Millisecond) if delay > time.Until(deadline) { return errors.New("capture retry exceeds delivery budget") } time.Sleep(delay) } return errors.New("capture attempts exhausted") } func main() { var mu sync.Mutex requests, accepted := 0, 0 firstID := "" receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() if r.Method != http.MethodPost { w.WriteHeader(405); return } var event Event if err := json.NewDecoder(r.Body).Decode(&event); err != nil { http.Error(w, err.Error(), 400); return } if event.Message == "" || event.Stack == "" || event.Context["run_id"] != "lesson-demo-17" { http.Error(w, "missing attribution evidence", 400); return } requests++ id := r.Header.Get("Idempotency-Key") if requests == 1 { firstID = id } if id == "" || id != firstID { http.Error(w, "unstable event identity", 400); return } if requests == 1 { w.Header().Set("Retry-After", "1") http.Error(w, "fixture rate limit", 429); return } accepted++ w.WriteHeader(http.StatusNoContent) })) defer receiver.Close() original := errors.New("synthetic answer-check failure") event := Event{ Message: original.Error(), Stack: string(debug.Stack()), Environment: "local-fixture", Context: map[string]any{ "run_id": "lesson-demo-17", "failed_step": "answer-check", "calls": []map[string]any{ {"step": "hint", "attempt": 1, "cost_status": "unknown"}, {"step": "answer-check", "attempt": 1, "cost_status": "unknown"}, }, }, } body, err := json.Marshal(event) if err != nil { panic(err) } if os.Getenv("CAPTURE_DISABLED") == "1" { fmt.Println("delivery disabled; lesson outcome:", original) return } err = post(&http.Client{Timeout: time.Second}, receiver.URL+"/v1/errors/capture", os.Getenv("INFRAI_API_KEY"), "lesson-demo-17-answer-check-event-1", body) if err != nil { panic(err) } mu.Lock() defer mu.Unlock() if requests != 2 || accepted != 1 { panic("delivery verification failed") } fmt.Println("verified: 2 delivery attempts, 1 acceptance; lesson outcome:", original) } 

A stable identity covers retries of this particular event. Generate a new identity for a different failure, and persist it alongside a queued event if delivery must survive a restart. The fixture rejects its first request before acceptance; the final count verifies this sequence, not a remote deduplication guarantee. Transport errors surface instead of triggering blind retries.

The demo doesn't implement a durable ledger or background queue. Its purpose is narrower: make the exception snapshot and delivery behavior inspectable before introducing student traffic. Production accounting persistence belongs before the sender, so disabling that sender cannot erase the record of successful model work. The fallback starts at 100ms; the fixture asks for 1000ms through Retry-After. These are test settings, not observed service latency.

Verify attribution and rehearse rollback

The acceptance line confirms two delivery attempts and one acceptance while preserving the synthetic lesson failure. Inspect the serialized event locally: both call entries must survive, attempt numbers must remain unchanged, and the retry must preserve the event identity. A duplicated ledger entry is an accounting error even if the dashboard shows one tidy error group.

Retain evidence.

Measure agent latency around the actual loop with a monotonic elapsed-time measurement, then measure delivery latency separately. Don't add the receiver's deliberate one-second backoff to learner-facing model duration. Reconcile costs against available per-call metadata and retain provider request identifiers for mismatches. If the failing response lacks usable billing evidence, label the attribution incomplete and reconcile later; assigning zero manufactures certainty precisely where the postmortem needs evidence.

For rollback, set CAPTURE_DISABLED to 1 in the process environment and run the same program. It should report delivery disabled and preserve the same lesson outcome without sending a request. The local receiver's in-memory records are disposable. Production accounting records aren't.

Before rollout, repeat the exercise with an invalid payload and a receiver that stays rate-limited. Require delivery to stop under the retry policy while the lesson is never executed again. This sample allows two attempts, honors Retry-After when the requested wait fits, and stops rather than retrying early when it exceeds the three-second retry-wait budget. That budget isn't a strict end-to-end deadline; each HTTP request has its own timeout. A production worker needs a durable failure path and independent monitoring for undelivered events.

Choose the receiver by the evidence it preserves

Successful delivery proves transport acceptance. It doesn't prove that the next incident will reach the pager. Keep notification ownership, trace investigation, and missed-job detection as explicit requirements rather than assuming an error dashboard covers them.

Option Useful fit for this tutoring workflow Trade-off to evaluate
Sentry An error-focused Node.js integration is a useful starting point when exception context drives investigation. Validate run identifiers and cost evidence separately; an exception alone cannot account for earlier successful calls.
Datadog Consider an application monitoring workflow when investigation needs broader service context. Evaluate the instrumentation scope and correlation work alongside the small capture contract; broader coverage adds integration decisions.
Grafana Consider a telemetry investigation workflow when the team already works from shared operational views. Prove error-to-run navigation and choose the supporting telemetry components explicitly. A dashboard alone isn't a cost ledger.
OpenTelemetry Its logs signal provides a standards reference for associating records with execution context. Correlation identifiers don't establish billing evidence or an operational paging policy.

These are evaluation criteria, not findings from a comparative benchmark. The documentation below provides starting points for reproducing the same exercise; no latency ranking or automatic agent-cost accounting claim follows from this example.

Infrai combines a self-describing REST API with a single key and unified billing: one key, one bill across 20 modules. Public discovery supplies schemas and runnable examples, so wiring capture starts with inspecting a capability rather than learning a new SDK; sharing the credential across backend capabilities reduces handoffs between the tutoring runtime and its error sender. Its specified per-call cost, vendor, latency, and request metadata supports the ledger when the application preserves that evidence at each call boundary. The trade-off is workflow ownership: keep paging in a separately owned process, use a trace investigation tool when span-tree navigation is required, and use a heartbeat monitor such as Healthchecks for lesson-processing jobs that never run. Posting an exception doesn't satisfy those requirements.

Choose the receiver that passes the failure exercise while leaving a reconcilable ledger. Ask what page fired. Then ask which calls the failed lesson paid for.

References