SDK Observability
Record ArmorIQ SDK sessions, plan traces, policy decisions, tool activity, model usage, latency, and errors.
SDK Observability
The ArmorIQ SDK is the primary telemetry writer for application integrations. It records one trace for each IAP plan, nests policy, tool, generation, and event spans beneath that trace, and groups multiple traces into one continuous session.
Session
└── IAP plan trace
├── Policy call
├── Tool execution
├── Model generation
└── Event or errorTypeScript SDK
Install @armoriq/sdk 0.6.3, configure a session, record generations, flush, and shut down
Python SDK
Install armoriq-sdk 0.6.4, configure SessionOptions, use background flushing, and close safely
Shared Behavior
- Observability is enabled by default for SDK sessions and can be disabled per session.
- Telemetry errors are fail-open and do not change policy decisions.
- Completed traces are buffered in memory and shipped in batches to
POST {endpoint}/observability/spans. - Network errors and
5xxresponses are retried with exponential backoff;4xxresponses are logged and dropped immediately, no retry. - Explicit flush and shutdown operations wait for delivery attempts, so they can add boundary latency.
- Tool inputs, outputs, prompts, and completions can contain sensitive data and are not automatically redacted by the shared SDK recorders.
- Neither SDK uses OpenTelemetry. Both ship a bespoke, ArmorIQ-specific span/trace schema directly to the ArmorIQ backend: there is no OTel SDK, no OTLP exporter, and no OTel-compatible wire format today.
Span Primitives
Both SDKs export the same set of low-level primitives for advanced integrations that need direct control over trace and span lifecycles. The session API (session.startPlan() / session.start_plan(), recordGeneration() / record_generation(), etc.) is built on top of these and is the recommended entry point for most integrations. Reach for the primitives below when you are building a product bridge or a custom instrumentation layer.
| Primitive | TypeScript export | Python export |
|---|---|---|
| Start / end a trace | startTrace, endTrace | start_trace, end_trace |
| Open / close a container span | openSpan, closeSpan | open_span, close_span |
| Record a pre-finished span | recordSpan | record_span |
| Record a policy decision | recordPolicyCall | record_policy_call |
| Record model usage | recordGeneration | record_generation |
| Record a point-in-time event | recordEvent | record_event |
| Flush queued batches | flushObservability | flush_observability |
| Cost calculation | computeCostUsd, MODEL_PRICES | compute_cost_usd, MODEL_PRICES |
| Auto-tag a finished trace | deriveTraceSummary (internal only) | derive_trace_summary (public export) |
All of these are exported from the package root: @armoriq/sdk in TypeScript, armoriq_sdk.observability in Python.
Parity is complete for the span primitives, including openSpan/closeSpan and the auto-tagging summary logic. Both exist in both SDKs. The one real asymmetry: TypeScript's deriveTraceSummary is an internal helper (not re-exported from the package root, only called by endTrace() inside the recorder), while Python's derive_trace_summary is exported from armoriq_sdk.observability and callable directly. Functionally the two SDKs derive the same trace tags and summary string.
startTrace / endTrace
startTrace(recorder, name, attributes?, sessionId?) opens a new trace and returns a TraceContext (traceId, sessionId, name, startTimeMs, attributes). endTrace(recorder, ctx, opts?) closes it: it sets the end time and duration, applies an explicit status/errorMessage if you pass one, and (if you didn't already set tags or attributes.output) fills them in by calling deriveTraceSummary() over the trace's finished spans. The finished (trace, spans) pair is then handed to the shipper and dropped from the in-memory buffer.
import { startTrace, endTrace } from '@armoriq/sdk';
const ctx = startTrace(recorder, 'iap.plan', { goal: 'Fetch and analyze data' });
// ... open/close or record spans against ctx ...
endTrace(recorder, ctx, { status: 'ok' });from armoriq_sdk.observability import start_trace, end_trace
ctx = start_trace(recorder, "iap.plan", attributes={"goal": "Fetch and analyze data"})
# ... open/close or record spans against ctx ...
end_trace(recorder, ctx, status="ok")openSpan / closeSpan
Use this pair when a unit of work has a real start and end that you want to bracket explicitly: for example, wrapping an entire startPlan() → dispatch() → report() sequence in one container span. openSpan mints a span with endTime: null and returns its id immediately (it's a no-op returning an id even if the recorder is disabled, so call sites never need to special-case it). closeSpan looks the span up by id and sets its end time, and optionally a status, durationMs, and errorMessage. It's a safe no-op if the recorder is disabled, the trace is unknown, or the span was already shipped.
This is the pattern the SDKs use internally: every session method (startPlan, enforceLocal/enforce_local, check, report, dispatch, ...) opens a span at the start of its _impl call and closes it in a finally block.
import { openSpan, closeSpan } from '@armoriq/sdk';
const spanId = openSpan(recorder, ctx, { name: 'iap.plan.start' });
let status: 'ok' | 'error' = 'ok';
let errorMessage: string | undefined;
const startedAt = Date.now();
try {
await runPlanStep();
} catch (err) {
status = 'error';
errorMessage = (err as Error).message;
throw err;
} finally {
closeSpan(recorder, ctx, spanId, {
status,
durationMs: Date.now() - startedAt,
errorMessage,
});
}from armoriq_sdk.observability import open_span, close_span
span_id = open_span(recorder, ctx, name="iap.plan.start")
status = "ok"
error_message = None
started_at = time.monotonic()
try:
run_plan_step()
except Exception as err:
status, error_message = "error", str(err)
raise
finally:
close_span(
recorder, ctx, span_id,
status=status,
duration_ms=(time.monotonic() - started_at) * 1000,
error_message=error_message,
)recordSpan, recordPolicyCall, recordGeneration, recordEvent
These four build and record a span in a single call (startTime === endTime and durationMs: 0 at construction), so they're the right choice when the work being recorded already finished (you have the full input/output/result in hand) and there's nothing to bracket with open/close.
recordSpan(recorder, ctx, span)is the lowest-level primitive: it takes an already-built span record, validates it, and appends it to the trace. Invalid spans are logged and dropped, never thrown.recordPolicyCall,recordGeneration, andrecordEventare all implemented on top of it.recordPolicyCall(recorder, ctx, attrs, parentSpanId?)records apolicy_callspan from a policy decision:policyId,decision("allow" | "deny" | "hold" | "ask"),source,reason,input/output,enforcementAction, and related fields. The span'sstatusis derived automatically:okif the decision isallow, otherwisedenied.recordGeneration(recorder, ctx, attrs, parentSpanId?)records agenerationspan for one LLM call:model,inputTokens,outputTokens, optionalcacheReadTokens/cacheWriteTokens, optionalprompt/completion/finishReason, and optionalcostUsd. If you don't passcostUsd, it's computed for you viacomputeCostUsd(); an explicit value always overrides the computed one.recordEvent(recorder, ctx, attrs, parentSpanId?)records a point-in-timeeventspan from{ message, level? }:leveldefaults toinfo, andmessagebecomes the span name (truncated to 120 characters).
import { computeCostUsd, recordGeneration } from '@armoriq/sdk';
recordGeneration(recorder, ctx, {
model: 'gpt-4o',
inputTokens: 420,
outputTokens: 96,
cacheReadTokens: 0,
cacheWriteTokens: 0,
costUsd: computeCostUsd('gpt-4o', 420, 96),
prompt: null,
completion: null,
finishReason: 'stop',
});from armoriq_sdk.observability import compute_cost_usd, record_generation
record_generation(
recorder, ctx,
model="gpt-4o",
input_tokens=420,
output_tokens=96,
cache_read_tokens=0,
cache_write_tokens=0,
prompt=None,
completion=None,
finish_reason="stop",
)Most integrations should reach for the session-level recordGeneration() (TypeScript) / session.record_generation() (Python) instead of calling the module-level primitive directly. The session already tracks the active plan trace context for you. See the TypeScript and Python guides for the session-level API.
flushObservability
flushObservability(recorder) (flush_observability in Python) does not end any active trace: it only moves already-ended traces onto the shipper's send queue (a defensive safety net; endTrace/end_trace normally does this already) and then triggers the shipper's own network flush. In-flight traces that haven't been ended yet are left untouched, and a warning is logged noting how many were retained. It returns an { accepted, rejected } count and never throws: shipping failures are logged and dropped, not surfaced to the caller.
computeCostUsd and MODEL_PRICES
computeCostUsd(model, inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?) (compute_cost_usd in Python, same parameter order) looks up per-model pricing in MODEL_PRICES and returns a cost in USD rounded to 6 decimal places, matching the backend's Decimal(12,6) cost_usd column. An unknown model returns 0: the span is still recorded, just with zero computed cost.
MODEL_PRICES is a static, manually maintained table (not auto-synced with vendor pricing pages) with four fields per model, all expressed per 1,000 tokens:
interface ModelPrice {
inputPer1kUsd: number;
outputPer1kUsd: number;
cacheReadPer1kUsd: number;
cacheWritePer1kUsd: number;
}Example entries (identical figures in both SDKs):
| Model | Input / 1K | Output / 1K | Cache read / 1K | Cache write / 1K |
|---|---|---|---|---|
claude-sonnet-4-20250514 | $0.003 | $0.015 | $0.0003 | $0.00375 |
claude-opus-4-20250514 | $0.015 | $0.075 | $0.0015 | $0.01875 |
gpt-4o | $0.0025 | $0.01 | $0.00125 | $0.0025 |
gpt-4o-mini | $0.00015 | $0.0006 | $0.000075 | $0.00015 |
The TypeScript table additionally covers claude-sonnet-5, claude-opus-4-8, claude-haiku-4-5, and gpt-5.6-sol, gpt-4.1/gpt-4.1-mini at time of writing. Treat both tables as best-effort and non-billing-grade: check the current source (model-prices.ts / model_prices.py) for the exact model list before relying on it for cost reporting.
deriveTraceSummary (auto-tagging)
Called automatically by endTrace/end_trace once a trace's spans are all recorded, deriveTraceSummary(spans, product) computes two things it will backfill onto the trace unless you already set them explicitly:
tags:[product, ...up to 8 distinct tool names seen across the trace's spans, decisionVerdict], wheredecisionVerdictis"allowed","blocked","mixed", or omitted if there were no policy-call spans.output: a short human-readable summary like"5 checks · all allowed"or"4 checks · 2 blocked, 1 pending", ornullif the trace has no policy-call spans.
A caller-supplied non-empty tags array, or an explicit attributes.output, always wins over the derived value. This only fills genuine gaps left by the caller.
ObservabilityShipper
Both SDKs use an internal shipper (not meant to be constructed directly by SDK consumers) that batches finished traces and POSTs them to {endpoint}/observability/spans:
- Runs its own periodic flush timer, default 5000ms (
flushIntervalMs/flush_interval_ms), a JS timer in TypeScript, a daemon background thread in Python. - Chunks the send queue into batches of at most 100 (
batchSize/batch_size) per POST. - Retries on
5xxresponses and network errors with exponential backoff (500ms, 1s, 2s), up to 3 attempts, then logs and drops the batch.4xxresponses are logged and dropped immediately, never retried. - The shipper's own send queue is unbounded; overflow protection instead lives in the recorder's in-memory ring buffer of in-flight (not-yet-ended) traces, capped by
maxBufferSize/max_buffer_size(default 1000): the oldest trace and all its spans are dropped, with a warning, once that cap is exceeded. Once a trace ends, it moves off the ring buffer and onto the shipper's queue, which isn't subject to this cap. durable: true/durable=Trueis accepted by both SDKs' config but disk-backed durability is not implemented in either: the recorder logs a one-time warning and continues with in-memory buffering only.
Explore and Diagnose
- Observability overview for architecture, freshness, retention, and supported producers
- Data model and schema for session, trace, span, token, and cost fields
- Observability dashboard for the implemented screens and 30-second polling behavior
- Troubleshooting for missing, delayed, or incomplete telemetry