NodeOps
UK

How-to: observability and logging

You want structured logs, metrics, and traces of every SDK HTTP call without leaking credentials into your log store.

At a glance

  • Package: @nodeops-createos/sandbox (npm)
  • Import: import { createClient } from "@nodeops-createos/sandbox"
  • Base URL: https://api.sb.createos.sh — override with CREATEOS_SANDBOX_BASE_URL
  • Auth: API key via the apiKey option or CREATEOS_SANDBOX_API_KEY

Wire the hooks

CreateosSandboxClient accepts an optional hooks bag on the constructor. Three callbacks fire around every non-streaming request:

HookFires
onRequestBefore fetch is called, on every attempt.
onResponseAfter fetch settles (success or HTTP error).
onRetryBetween attempts, after the response (or network error) but before the backoff sleep.

Hooks are await-ed in the request path so an async hook orders deterministically against the request it describes. Keep hook work cheap, or dispatch slow side-effects without returning the promise.

A throw inside a hook is swallowed — a misbehaving observer cannot crash a real request.

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient({
4 apiKey: process.env.CREATEOS_SANDBOX_API_KEY,
5 hooks: {
6 onRequest(ctx) { /* ... */ },
7 onResponse(ctx) { /* ... */ },
8 onRetry(ctx) { /* ... */ },
9 },
10});

See CreateosSandboxClientOptions for the full constructor reference.

Hook payloads

onRequestRequestHookContext

FieldTypeNotes
methodstringUppercase HTTP verb ("GET", "POST", …).
urlstringFull URL, userinfo stripped, sensitive query params redacted.
headersRecord<string, string>Outgoing headers; credential values replaced by "redacted".
attemptnumber1 on the first try, 2+ on retries.

onResponseResponseHookContext

Extends RequestHookContext with:

FieldTypeNotes
statusnumberHTTP status code.
durationMsnumberElapsed time for this fetch call (ms).
requestIdstring | undefinedx-request-id header from the server, when present.

onRetryRetryHookContext

Extends ResponseHookContext (minus status) with:

FieldTypeNotes
reasonRetryReason"network" · "status" · "rate-limit".
statusnumber | undefinedHTTP status that triggered the retry; undefined for network errors (no response received).
delayMsnumberMilliseconds the SDK will sleep before the next attempt.

RetryReason breakdown:

  • "network"fetch threw before a response arrived. status and requestId are undefined.
  • "status" — a retryable status code (408/500/502/503/504 on idempotent methods). delayMs is exponential backoff + jitter.
  • "rate-limit" — server returned 429 or 503 with a Retry-After header. delayMs honors that header value.

Payloads are pre-redacted

The SDK redacts hook payloads before your code sees them. You do not need to scrub credentials yourself when using the hooks — the values are never passed to you in the first place.

The url and headers fields in every hook context are produced by redactUrl and redactHeaders at request build time, before any hook fires. What is redacted:

Headers — any header whose lowercased name is in SENSITIVE_HEADER_NAMES (authorization, cookie, set-cookie, x-api-key, x-access-token, x-auth-token, x-csrf-token, proxy-authorization), plus any header whose name ends in "-token" or "-key". Values become the literal string "redacted", so your logs stay greppable.

Query params — any key in SENSITIVE_QUERY_PARAMS (token, api_key, apikey, access_token, auth_token, password, secret). Same "redacted" substitution.

URL userinfousername:password@ in the URL is stripped.

Recipe: structured log line per request

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient({
4 apiKey: process.env.CREATEOS_SANDBOX_API_KEY,
5 hooks: {
6 onRequest({ method, url, attempt }) {
7 console.debug(JSON.stringify({ event: "sdk.request", method, url, attempt }));
8 },
9 onResponse({ method, url, status, durationMs, attempt, requestId }) {
10 console.debug(
11 JSON.stringify({
12 event: "sdk.response",
13 method,
14 url,
15 status,
16 durationMs: Math.round(durationMs),
17 attempt,
18 requestId,
19 }),
20 );
21 },
22 },
23});

Recipe: retry counter metric

Wire onRetry to a metrics counter. The reason field lets you split rate-limit retries from transient 5xx:

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3// Replace with your metrics client (Prometheus, Datadog, etc.).
4function incrementCounter(name: string, labels: Record<string, string>): void {
5 /* ... */
6}
7
8const client = new CreateosSandboxClient({
9 apiKey: process.env.CREATEOS_SANDBOX_API_KEY,
10 hooks: {
11 onRetry({ method, url, reason, status, delayMs, attempt }) {
12 incrementCounter("sdk_retry_total", {
13 method,
14 reason,
15 status: String(status ?? "network"),
16 });
17 console.warn(
18 JSON.stringify({ event: "sdk.retry", method, url, reason, status, delayMs, attempt }),
19 );
20 },
21 },
22});

Recipe: safe logging outside hooks

If you log raw fetch calls or HTTP details from your own code (not inside a hook), use the exported redaction helpers. They are pure functions — non-mutating, no side effects — and mirror exactly what the SDK applies to hook payloads internally.

TypeScript
1import {
2 CreateosSandboxClient,
3 redactHeaders,
4 redactUrl,
5} from "@nodeops-createos/sandbox";
6
7// Your own middleware / interceptor — not a hook:
8function logOutbound(method: string, url: string, headers: Headers): void {
9 console.debug(
10 JSON.stringify({
11 event: "custom.request",
12 method,
13 url: redactUrl(url), // strips userinfo, redacts sensitive params
14 headers: redactHeaders(headers), // replaces credential values with "redacted"
15 }),
16 );
17}

These helpers are not auto-wired into your logger — call them explicitly wherever you construct or log raw requests. See redactHeaders / redactUrl / redactQuery for full signatures, plus SENSITIVE_HEADER_NAMES and SENSITIVE_QUERY_PARAMS if you need to inspect the lists.

Recipe: OpenTelemetry span per request

Start a span in onRequest, end it in onResponse. Key on method + url + attempt to correlate across the pair, because retries fire both hooks with an incremented attempt.

TypeScript
1import { trace, SpanStatusCode } from "@opentelemetry/api";
2import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
3
4const tracer = trace.getTracer("createos-sandbox-sdk");
5const spans = new Map<string, ReturnType<typeof tracer.startSpan>>();
6
7const client = new CreateosSandboxClient({
8 apiKey: process.env.CREATEOS_SANDBOX_API_KEY,
9 hooks: {
10 onRequest({ method, url, attempt }) {
11 const key = `${method} ${url} ${attempt}`;
12 spans.set(
13 key,
14 tracer.startSpan(`createos-sandbox ${method}`, {
15 attributes: { "http.method": method, "http.url": url, "sdk.attempt": attempt },
16 }),
17 );
18 },
19 onResponse({ method, url, status, durationMs, requestId, attempt }) {
20 const key = `${method} ${url} ${attempt}`;
21 const span = spans.get(key);
22 if (span) {
23 span.setAttributes({
24 "http.status_code": status,
25 "sdk.duration_ms": Math.round(durationMs),
26 ...(requestId ? { "sdk.request_id": requestId } : {}),
27 });
28 if (status >= 400) span.setStatus({ code: SpanStatusCode.ERROR });
29 span.end();
30 spans.delete(key);
31 }
32 },
33 },
34});

Streaming requests bypass hooks

Sandbox.streamCommand and TemplatesApi.followLogs open a persistent NDJSON connection and go through CreateosSandboxHttp.stream, not the retry loop. Hooks do not fire for streaming requests — there is no retry to observe and no clean "done" point for onResponse. Wrap your for await loop in your own log or metric if you need per-stream tracing.

Reference

100,000+ Builders. One Workspace.

Get product updates, builder stories, and early access to features that help you ship faster.

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.