Production & Failure Modes
What the SDK does inside your process, what it costs you when our backend is down, and what you lose.
This page describes what the Risicare SDK does inside your production process:
what it costs your request path, what happens when our backend is unreachable,
and exactly which spans you lose. Every number here was measured against the
published packages — Python risicare 0.2.0 (with risicare-core 0.1.6) and
JavaScript risicare 0.5.0 — not against internal builds.
If you are evaluating whether to put this library in a hot path, read the first two sections. They are the ones that decide it.
What the SDK does not do to your application
These are the guarantees. They hold whether our backend is healthy, degraded, or completely unreachable.
Tracing never raises into your code. An export failure is logged, never thrown. No span operation propagates an exception into your call frame. If our gateway returns 500 for an hour, your application does not see an error from us.
Your thread does not block on export. Spans are handed to an in-process queue and written by a background worker. Span creation cost is unchanged by backend health — measured over 20,000 spans on each arm:
| Backend state | p50 | p95 | p99 |
|---|---|---|---|
| Healthy | 0.0141 ms | 0.0162 ms | 0.0236 ms |
| Returning 500 to every request | 0.0119 ms | 0.0140 ms | 0.0163 ms |
Tens of microseconds, and the dead-backend arm is not slower. (It is marginally faster, because once the queue is full, dropping a span is cheaper than enqueuing one.) Treat these as an order of magnitude on your own hardware, not a guarantee of a specific figure.
Memory is bounded. The queue is capped at max_queue_size, default
10,000 spans. When it is full, further spans are dropped rather than buffered.
The queue does not grow without limit during an outage.
A clean shutdown does not lose what it holds. shutdown() drains the queue
within a budget of 5,000 ms by default, reserving the last 100 ms to close
the exporter. Anything still queued when that budget expires is counted and
announced with a warning — it is never discarded silently.
Always shut down explicitly
Call risicare.shutdown() (Python) or await risicare.shutdown() (JS) before
your process exits. Without it, whatever is still in the queue at exit is lost.
What you lose when our backend is unreachable
This is the part that costs you data, and it is the reason this page exists.
The exporter has a circuit breaker. After 5 consecutive failed export calls it stops attempting exports entirely for a 60-second cooldown. During that cooldown no request is made — even if the backend has already recovered.
Count failed calls, not seconds
An export call is one batch, not one span and not one HTTP request. Each export call makes up to 3 HTTP attempts internally, with 0.1 s and 0.2 s backoff between them, before it counts as a single consecutive failure.
So the breaker opens after 5 failed calls — which is up to 15 HTTP requests. Where that lands in wall-clock time is not fixed: it depends on your span rate and on how quickly your endpoint fails. An endpoint that refuses connections instantly burns those 5 calls in well under a second; one that times out slowly takes far longer. This is why the threshold is documented in calls rather than seconds — any second-based figure would be specific to one harness.
Measured
Driving the published HttpExporter against a loopback sink that returned 500 to
everything, then flipping that sink healthy:
| Event | Observed |
|---|---|
| Export calls before the breaker opened | 5 |
| HTTP requests that reached the sink | 15 (3 per call) |
| Requests reaching the sink once open | 0 |
| Sink made healthy at | t = 0 |
| First request to reach the healthy sink | t = 60.1 s |
The sink was healthy and idle for the whole minute. The SDK did not probe it once. That is the cost the breaker imposes: a brief outage bills you for the outage plus up to a further 60 seconds.
A failed probe restarts the clock
When the cooldown expires the exporter allows one half-open attempt. If that attempt succeeds, the failure counter resets and normal export resumes. If it fails, the cooldown is re-armed for another full 60 seconds. A backend that is flapping rather than cleanly down can hold the circuit open indefinitely.
Which spans survive
When the queue is full the incoming span is dropped. The oldest spans already queued are kept.
Measured by creating 20,000 spans against a dead backend, then restoring it: exactly 10,500 distinct spans were delivered, contiguously numbered from the first span created — 10,000 from the queue plus one in-flight batch of 500. Spans 10,501 onward were dropped. If you are debugging an incident from a truncated trace, you are looking at its beginning, not its end.
Dropped spans are counted by reason (queue_full, transport_refused,
shutdown_residue) and surfaced through the processor's metrics.
Defaults that govern this
| Setting | Python default | JS default |
|---|---|---|
| Circuit breaker threshold | 5 consecutive failed export calls | 5 |
| Circuit breaker cooldown | 60 s | 60 s (6e4 ms) |
| HTTP attempts per export call | 3 | 3 |
| Batch re-queue limit before discard | 3 | 3 |
batch_size | 500 | 500 |
batch_timeout_ms | 1,000 | 1,000 |
max_queue_size | 10,000 | 10,000 |
| Export request timeout | 5 s | 5 s |
| Shutdown budget | 5,000 ms | 5,000 ms |
The OTLP exporter applies the same 5-failure / 60-second policy as the default HTTP exporter.
Multi-worker servers and fork()
If you run gunicorn, uvicorn with workers, or celery with the default
prefork pool, your process forks after startup. This matters, because a forked
child inherits the parent's exporter state — including its open sockets and its
circuit-breaker counters.
The Python SDK registers an os.register_at_fork hook that resets that state in
the child. The hook first shipped in 0.1.14.
| Version | Post-fork hook |
|---|---|
0.2.0 | Yes |
0.1.14 | Yes |
0.1.12 and earlier | No |
There is no 0.1.13 release. If you are on 0.1.12 or earlier, upgrade — this
is the single highest-value upgrade for anyone running a forking server.
What the hook actually does, measured by dirtying the parent's exporter state and then inspecting it in the child:
| State in the child | 0.2.0 | 0.1.12 |
|---|---|---|
| Inherited HTTP client handle | dropped, rebuilt lazily by the child | inherited from the parent |
consecutive_failures (parent set to 4) | reset to 0 | 4 |
| Circuit-open deadline (parent set) | reset to 0.0 | inherited |
On 0.1.12 the child shares the parent's socket pool — which scrambles HTTP/2
stream state for both sides — and starts life one failure away from an open
circuit that it never earned.
# Which version are you actually running?
python -c "import risicare; print(risicare.__version__)"On 0.1.14+ no post_fork hook of your own is required: initialise once, before
the fork, and the child repairs itself.
Do not validate fork behaviour on macOS
On macOS a forked child that performs any network I/O crashes before it can
send anything. We reproduced this with and without the Risicare SDK loaded: a
control script that never imports risicare — parent starts a thread, child
makes one HTTP request — dies with objc[…]: +[NSNumber initialize] may have been in progress in another thread when fork() was called. … Crashing instead.
This is a macOS Objective-C fork-safety property, not a Risicare defect. Validate multi-worker behaviour on Linux, which is what you deploy on.
Silent failure modes
These are the ones that cost people days, because nothing crashes.
No API key — spans are created and dropped
Without an API key no exporter is configured. Spans are still created, and then
discarded. The SDK emits a WARNING naming the endpoint when this happens, so it
is visible at Python's default logging level — but only if you actually read the
process log, and it is a single line at startup.
import risicare
risicare.init(api_key="rsk-...") # or set RISICARE_API_KEYIf you see no data in the dashboard, check this first.
debug=True writes unredacted content to stdout
In the Python SDK, debug=True attaches a console exporter unconditionally —
including when an API key is set. That exporter writes every span attribute to
stdout with no redaction. Values are truncated at 80 characters, which is
truncation, not masking: anything shorter than 80 characters is printed in
full.
We measured this A/B at the file-descriptor level, with the process's stdout redirected to a file, using reserved test values:
| Value on a span attribute | debug=True | debug=False |
|---|---|---|
SSN-shaped (987-65-4320) | printed in full | absent |
Visa test PAN (4111111111111111) | printed in full | absent |
Email (user@example.invalid) | printed in full | absent |
Documentation IP (192.0.2.10) | printed in full | absent |
Both arms delivered their span to the sink, so the debug=False column is a real
absence, not a run that did nothing.
debug=True does not print the HTTP status
debug=True sets a config flag. The HTTP status of a failed export is written
by the SDK logger, which needs a log level, not that flag. Measured against
a backend returning 500, over three spans:
| HTTP-status lines | Unredacted span dumps | |
|---|---|---|
debug=True | 0 | 3 |
Logger at DEBUG | 6 | 0 |
So debug=True gives you the payloads you did not ask for and none of the
diagnostics you did. Use the logger instead.
import logging
logging.getLogger("risicare").setLevel(logging.DEBUG)The JavaScript SDK differs here: it attaches its console exporter only when
debug is set and no API key is present. A configured JS production process
does not hit this.
Content is captured by default, and the endpoint defaults to production
trace_content defaults to true in both SDKs, and the endpoint defaults to
https://app.risicare.ai in both. Nothing needs to be switched on for prompt and
completion content to leave your process.
To turn the automatic capture off:
import risicare
risicare.init(api_key="rsk-...", trace_content=False)
# or: export RISICARE_TRACE_CONTENT=falsetrace_content only gates automatic capture
trace_content=False stops the provider and framework integrations from
capturing prompt and completion content. It is not enforced at the tracer or
the exporter, so attributes your own code sets with set_attribute() are still
exported. We verified this: with RISICARE_TRACE_CONTENT=false, a manually-set
attribute still arrived at the sink.
If you need content filtered regardless of where it came from, use mask.
mask — the only pre-export redaction hook
mask(key, value) -> value runs on every content-bearing field before anything
leaves your process. It is the only such hook in either SDK. It receives the
attribute key, so you can redact content without erasing structural values
like model names.
import re
import risicare
SENSITIVE = re.compile(r"(ssn|card|email|authorization|api[_-]?key)", re.I)
def mask(key: str, value):
if SENSITIVE.search(key):
return "[REDACTED]"
return value
risicare.init(api_key="rsk-...", mask=mask)Verified against a loopback sink, reading the request body:
| On the wire | With mask | Without mask |
|---|---|---|
| SSN / PAN / email values | absent | present |
[REDACTED] marker | present | absent |
gen_ai.request.model (gpt-4o) | present | present |
Structural values survive; content is replaced. Note the right-hand column: with
no mask and default settings, that content goes out.
The mask runs on the export worker, not your request thread — but a slow mask stalls the drain. If it raises, that one field becomes a marker string and the span is still exported.
Environment variables
Fourteen environment variables are honoured on the risicare.init() path in
Python. Explicit arguments to init() take precedence over all of them.
| Variable | Default |
|---|---|
RISICARE_API_KEY | unset |
RISICARE_ENDPOINT | https://app.risicare.ai |
RISICARE_ENVIRONMENT | unset |
RISICARE_SERVICE_NAME | unset |
RISICARE_SERVICE_VERSION | unset |
RISICARE_PROJECT_ID | unset — deprecated, ignored by the gateway; removed in v1.0 |
RISICARE_TRACING | unset |
RISICARE_TRACE_CONTENT | true |
RISICARE_SAMPLE_RATE | 1.0 (an unparseable value falls back to 1.0) |
RISICARE_DEBUG | false |
RISICARE_OTLP_ENDPOINT | unset |
RISICARE_OTLP_HEADERS | unset |
RISICARE_OTEL_BRIDGE | false |
RISICARE_STRICT_INSTRUMENTATION | unset |
RISICARE_FIX_* is not read by init()
The names RISICARE_FIX_ENABLED, RISICARE_FIX_CACHE, RISICARE_FIX_CACHE_TTL,
RISICARE_FIX_AUTO_REFRESH, RISICARE_FIX_REFRESH_INTERVAL,
RISICARE_FIX_DRY_RUN, RISICARE_AB_TESTING and RISICARE_TRACK_EFFECTIVENESS
exist in the package, but they are only read when a FixRuntimeConfig is built
from the environment. risicare.init() does not do that, so setting them has
no effect on the normal path. We measured this: with
RISICARE_FIX_ENABLED=false, the runtime still started and still made its
request.
Do not rely on them to turn anything off.
The JavaScript SDK reads fourteen variables of its own, including four with no
Python equivalent: RISICARE_BATCH_SIZE, RISICARE_BATCH_TIMEOUT_MS,
RISICARE_MAX_QUEUE_SIZE and RISICARE_COMPRESS.
Outbound requests you should expect
When an API key is set, init() starts a fix runtime alongside the exporter. It
issues a GET to /api/v1/fixes/active on your configured endpoint at startup.
If your egress rules only allow the span-ingest path, allow this one too or
expect a warning in your logs.
Verifying delivery yourself
Two traps if you write your own check:
The SDK's success signal is the HTTP status, nothing more. The exporter
treats any response below 300 as success and never parses the response body.
So "the SDK did not warn" means "the endpoint returned a 2xx" — it is not
evidence that every span in that batch was stored. If you need to prove
end-to-end delivery, count what arrives at the far end rather than trusting the
absence of a warning.
In JavaScript, traceAct and traceThink return a wrapper you must call.
Building the wrapper and never invoking it emits no span — and every check then
reads a legitimate zero for the wrong reason.
import * as risicare from 'risicare';
risicare.init({ apiKey: 'rsk-...' });
// Wrong: builds a wrapper, never runs it, emits nothing.
risicare.traceAct('never-invoked', async () => 'body never runs');
// Right: call what traceAct returns.
const wrapped = risicare.traceAct('act-invoked', async (x) => x * 2);
await wrapped(21);
await risicare.shutdown();Both forms above were executed against a loopback sink: only act-invoked
arrived. never-invoked produced no span at all.