Skip to main content
Every Nika run emits a structured stream of events. One event is one Event struct from nika-kernel::infra::event_sink, carried as one line of NDJSON on stdout (or any other sink you wire in). Events are deterministic, typed, and carry source IDs that correlate to the trace stack. The observability surface has five sibling channels, each a separate kernel trait β€” so impls can be swapped, sampled, or persisted independently:
ChannelTraitSemantics
EventsEventSinkStructured telemetry. May be sampled or dropped under load.
MetricsMetricsExporterCounters / histograms. Aggregated, low-cardinality.
TracesTracerProviderW3C-compatible spans. Sampled per-head.
BillingBillingSinkToken + cost ledger. Never dropped, never sampled.
AuditAuditSinkAppend-only, persist-or-fail. Compliance (GDPR, EU AI Act, Shield).
nika run --json streams the local event lane today. The kernel traits (EventSink, AuditSink, BillingSink, MetricsExporter, and TracerProvider) keep the interface stable while HTTP/SSE/OTLP exporters remain future interface work.

Event shape

#[non_exhaustive]
pub struct Event {
    pub id: EventId,                    // ULID
    pub run_id: RunId,                  // ULID of the current run
    pub trace_id: Option<TraceId>,      // W3C trace context
    pub kind: String,                   // e.g. "task.started"
    pub timestamp_ms: u64,              // Unix epoch milliseconds
    pub payload: serde_json::Value,     // kind-specific fields
}
Source: crates/nika-kernel/src/infra/event_sink.rs. Every event shares id, run_id, kind, and timestamp_ms; payload shape depends on the kind discriminator.

EventSink trait (L0.5)

#[trait_variant::make(EventSinkDyn: Send)]
pub trait EventSink: Send + Sync + crate::sealed::Sealed {
    async fn emit(&self, event: Event) -> Result<(), NikaError>;
}
Cancel-safety contract (from the source): impls MUST NOT maintain cross-emission state. Dropping a future mid-emit is semantically identical to a sampled-away event. Batched sinks flush on a separate task that owns the batch β€” the emit call itself is atomic or all-nothing. The trait is Sealed (ADR-014): external crates cannot implement it directly; they route through the workspace-controlled adapter path.

Event kinds

Every kind is emitted from exactly one call site in the codebase (INV-024). No ambiguous duplicates β€” if you see infer.chunk on the wire, you know exactly which crate produced it.

Workflow-level

  • workflow.started β€” a run begins.
  • workflow.completed β€” run ends, with status.
  • workflow.skipped β€” the run was gated off before any task started.

Task-level

  • task.started β€” a task begins executing.
  • task.completed β€” a task ends successfully.
  • task.failed β€” a task errored out; payload carries the typed error (code Β· category Β· transient Β· the spec error shape). On the wire the engine’s event slugs are snake_case (task_failed) β€” the dot-form here is the documentation vocabulary.
  • task.skipped β€” the task’s when: evaluated to false (or its empty for_each collection skipped it).
  • task.cancelled β€” the task never ran because its default gate became unsatisfiable (an upstream failed) or the run was cancelled β€” a normal terminal state under gate-based propagation.
  • task.retry β€” a retry attempt begins (emitted before task.started of the retry).

Verb-level

From nika-kernel::provider::InferEvent:
  • infer.delta β€” streaming text chunk (Delta { text }).
  • infer.tool_use_start β€” agent called a tool (ToolUseStart { id, name }).
  • infer.tool_use_delta β€” streaming partial JSON input (ToolUseDelta { id, partial_json }).
  • infer.thinking β€” extended thinking chunk (Thinking { text }).
  • infer.usage β€” token usage update (Usage(TokenUsage)).
  • infer.done β€” stream completed (Done { stop_reason, request_id, finish_reason_raw }).
From the other verbs:
  • fetch.request β€” HTTP request sent.
  • fetch.response β€” HTTP response received.
  • exec.output β€” a line of stdout/stderr.
  • invoke.call β€” tool invoked.
  • invoke.result β€” tool returned.
  • agent.iteration β€” one loop turn completed.

Meta

  • checkpoint β€” serializable snapshot (AgentCheckpoint β€” used for resume).
  • taint.violation β€” binding crossed a taint boundary (mirrors AuditRecord::TaintViolation).
  • budget.warning β€” nearing a BudgetDirective ceiling.
  • budget.exceeded β€” budget hard-stop triggered.

OTel GenAI semconv bridge (Q13)

Every InferRequest and InferResponse carries a GenAiAttrs struct that maps directly to the OpenTelemetry gen_ai.* semantic conventions:
#[non_exhaustive]
pub struct GenAiAttrs {
    pub system: GenAiSystem,             // anthropic | open_ai | google | …
    pub operation: GenAiOperation,       // chat | embedding | image_generation | …
    pub response_id: Option<String>,     // gen_ai.response.id
    pub response_model: Option<String>,  // gen_ai.response.model
    pub encoding_formats: Vec<String>,   // gen_ai.request.encoding_formats
    pub conversation_id: Option<String>, // gen_ai.conversation.id
    pub agent_id: Option<String>,        // gen_ai.agent.id
    pub agent_name: Option<String>,      // gen_ai.agent.name
}
Source: crates/nika-kernel/src/ai/genai.rs. This enforces cross-provider parity (Pre-launch Gate 2): no provider can silently drop an attribute the kernel exports. When Nika emits an infer.done event, the payload.gen_ai block is identical in shape across all providers. Cross-link: OTel GenAI spec.

AuditSink β€” the fifth channel (Q12)

AuditSink is separate from EventSink. Audit records are append-only, persist-or-fail β€” they MUST persist durably (fsync on a local log, synchronous HTTP POST to an append-only endpoint) before the trait method returns Ok. Buffering in memory is forbidden by contract.
#[non_exhaustive]
pub enum AuditRecord {
    CapabilityOverride { tenant_id, capability, reason },
    TaintViolation    { tenant_id, path, severity },
    CanaryLeaked      { tenant_id, canary_id },
    BudgetExhausted   { tenant_id, dimension, cap, observed },
    PolicyDenied      { tenant_id, rule_id, target },
    KeyUsed           { tenant_id, key_id },
    SecretRedacted    { tenant_id, location },
    Extension         { ns, name, payload },   // forward-compat escape hatch
}
Every variant carries a TenantId for multi-tenant routing. Extension is the forward-compat path (ADR-028) β€” new categories land there first and get promoted to a first-class variant once the namespace stabilises. Severity is separate: Info, Warn, Critical. A TaintViolation with Severity::Critical should kill the run; a CapabilityOverride with Severity::Info is an operator trail, not a failure.
AuditSink replaced an earlier ObservabilitySink design during the Q12 L0.5 architecture sweep (locked 2026-04-16, see docs/architecture/l0-l05-architecture-decisions.md). The separation matters: telemetry is best-effort; audit is compliance β€” different persistence guarantees, different failure modes, different sinks.

BillingSink β€” the ledger (separate trait)

Token + cost accounting uses its own trait because it has different dropping semantics than EventSink. Billing records are never dropped. Implementations persist on every call and surface failures to the caller, who kills the run if persistence fails (revenue integrity > workflow liveness). See nika-kernel::infra::billing::BillingSink + ADR-028 Β§4.

Telemetry seams (ADR-035)

Two reservations on the trace layer make the kernel OTel-ready without breaking any v0.8x caller:
  1. SpanGuard.parent_span_id: Option<SpanId> β€” explicit parent link so nested spans can be stitched into a tree without a thread-local stack (which tokio’s multi-thread runtime breaks).
  2. SpanRef { trace_id, span_id } β€” a copyable handle that propagates span identity across tokio::mpsc boundaries and spawned tasks without cloning the full SpanGuard.
Both land as additive, #[non_exhaustive] extensions. In a future minor they bridge zero-copy into OTel’s SpanContext when nika-observability-otel admits.

Enable the stream (target CLI)

nika run workflow.nika.yaml --json             # stdout NDJSON
nika run workflow.nika.yaml --json > run.log   # file
The CLI JSON lane is live. HTTP/SSE/OTLP export belongs to the future nika-serve interface; today the local binary owns the run stream.

Why five channels, not one

Because they have incompatible guarantees:
  • Events want volume + sampling. Dropping is fine.
  • Metrics want aggregation + low cardinality. Pre-aggregated.
  • Traces want causal structure + head sampling.
  • Billing wants durability + no drops. Every token metered.
  • Audit wants persist-or-fail + append-only. Compliance contract.
Forcing them through one sink loses either the sampling headroom (makes the hot path slow) or the compliance contract (drops records under load, fails the audit).
  • ADR-021 β€” YAML envelope. EventSink emits events that correlate to tasks inside spec.tasks, tagged with the workflow’s metadata.name.
  • ADR-028 β€” forward-compat reservation policy. Extension variants in AuditRecord + StopReason::Unknown(String) in InferResponse.
  • ADR-035 β€” telemetry seams (SpanGuard parent + SpanRef).

Event source

The nika-event crate β€” every kind + payload schema in source.

Providers

How the unified InferEvent stream maps to providers.

Bindings

Reading tasks.<id>.events from other tasks.

Architecture

L0.5 kernel traits the observability channels live in.