Reference
Events and exceptions
sendEvent for custom user actions, sendArtifactEvent for artifact lifecycle, and sendException for surfacing application errors.
Alongside the tracing APIs that record AI calls and traced functions, the SDK has three standalone capture helpers:
sendEvent()— record discrete user actions and feature usage that aren't operations you'd trace.sendArtifactEvent()— record lifecycle transitions on a persistent artifact (doc, deck, code file) that users progress through a funnel across sessions.sendException()— surface application errors from places outside a traced function (top-level handlers, background jobs).
All three work in server and browser runtimes.
Custom events — sendEvent()
Record custom user actions and feature usage. Each call produces a discrete OTel span — the span name is the event name passed in, and the property bag is attached as span attributes.
import { sendEvent } from "@runotis/sdk";
sendEvent("document.export", { format: "pdf", "doc.id": "doc-456" });
sendEvent("document.export", { format: "pdf" }, { userId: "user-123" });In ClickHouse raw_traces, the call above lands as SpanName="document.export" with format="pdf" and doc.id="doc-456" as attributes.
Span name = event name (no otis_ prefix)
sendEvent("foo") produces a span literally named "foo". It does not produce a span named "otis_sendEvent" with an event.name attribute. The four other SDK helpers (identifyUser, setUserProperties, setGroupProperties, sendFeedbackSignal) do prefix their span name with otis_, but sendEvent is the exception that uses the user-provided name verbatim. The SDK throws if you pass an event name starting with otis_ (that prefix is reserved).
The second argument is a property bag of arbitrary key/value pairs attached to the event. The third argument overrides identity fields (userId, sessionId, chatId) for this event only; by default events inherit identity from the active withContext or wrap scope.
Use sendEvent for user actions like exports, shares, searches, invites, and feature discovery — anything you want to analyze alongside AI interactions but that isn't itself an operation you're tracing.
See also Feedback signals for recording user ratings and corrections linked to specific AI responses.
Freeform user content — opt in with sensitive_
Pass-through attributes on sendEvent are treated as structural / identifier-shaped: they land verbatim as span attributes and are not PII-scanned. If a value carries freeform user content (a comment, a note, anything user-typed), name the key with a sensitive_ or sensitive. segment prefix to opt it into PII redaction.
sendEvent("document.export", {
format: "pdf", // structural — not scanned
"doc.id": "doc-456", // identifier — not scanned
sensitive_note: "Drafted with alice@acme.com", // scanned and redacted
});The match is case-insensitive and applies at any depth — sensitive_note, sensitive.note, artifact.sensitive_note, and Foo.SENSITIVE.bar all match. The boundary is ^ or ., so nonsensitive_thing does not match. Prefix the parent segment, not the leaf — sensitive_email matches; email.sensitive does not.
The same convention works inside sendArtifactEvent pass-through (the artifact. namespace is prepended automatically, so sensitive_note becomes artifact.sensitive_note and still matches) and inside any other namespace you author.
See PII redaction for the full scan-rule reference.
Artifact lifecycle — sendArtifactEvent()
Record a lifecycle transition on a persistent artifact — a document, deck, code file, design — that a user progresses through a funnel across sessions. Each call produces an otis_sendArtifactEvent span with artifact.id, artifact.stage, and optionally artifact.type. Drives the artifact_lifecycle lens (progression rate, time to terminal stage, abandonment rate, regeneration rate, and optional C3 outcome rate).
import { sendArtifactEvent } from "@runotis/sdk";
// Creation event — stages[0]
sendArtifactEvent("doc-abc123", { stage: "create", type: "deck" });
// Intermediate events (stages between first and terminal)
sendArtifactEvent("doc-abc123", { stage: "edit" });
// Terminal event — stages[-1]
sendArtifactEvent("doc-abc123", { stage: "share", templateId: "t-42" });Required fields:
artifactId— a stable, deterministic identifier for the artifact (e.g.doc-abc123). Canonicalized (trim + lowercase, must match[a-z0-9_.-]). Hashed client-side when identifier hashing is enabled; domain-separated from userId / sessionId / groupId.options.stage— the funnel stage this event represents. Stage names are free-form strings, ordered per-project via theartifact_lifecycle.stageslens parameter (default['create', 'edit', 'share']). Canonicalized likeartifactId.
Optional fields:
options.type— stored asartifact.typeon the span. Used byartifact_dead_typeandartifact_outcome_concentrationobservations to group artifacts by kind.- Any other key — passed through as
artifact.{key}attribute. Use for per-event metadata you want on the span (template id, word count, etc.).
Stage ordering is load-bearing
The artifact_lifecycle.stages parameter fixes the order. stages[0] is the creation event (drives progression_rate and regeneration_rate denominators), stages[-1] is the terminal event. Instrument every funnel step with a distinct stage name; omit stages the user never traverses. Changing stage ordering after instrumentation invalidates historical rollups.
Inherited identity: user/session/group context auto-attaches
otis_sendArtifactEvent spans inherit the active withContext() scope, so user.id, session.id, chat.id, and group.<type> attributes land automatically when the call happens inside a wrapped request. You don't need to pass user or group identity to sendArtifactEvent — the lens picks it up from the span.
Cross-entity attribution — withArtifactContext()
For artifact_outcome_rate and template-level outcome attribution to work, wrapped AI generations and any C3 outcome spans tied to an artifact must also carry artifact.id. The cleanest way is to wrap the whole scope:
import { withArtifactContext, sendArtifactEvent, sendEvent } from "@runotis/sdk";
sendArtifactEvent(deckId, { stage: "create", type: "deck" });
await withArtifactContext(deckId, async () => {
const { streamText } = otis.wrap(ai);
await streamText({ model, prompt }); // span carries artifact.id
sendEvent("deck.preview_opened", { recipient }); // carries artifact.id
});withArtifactContext(artifactId, fn) is a thin wrapper over otis.withContext({ artifactId }, fn) — it sets artifact.id as inherited context so every span created inside the scope (wrapped AI calls, otis.traced(), sendEvent, sendFeedbackSignal) picks it up automatically. The artifact ID is hashed idempotently on entry — already-hashed art_v1_ / art_v2_ values pass through.
You can also set artifactId directly on a WrapContext:
await otis.withContext({ artifactId: deckId, userId, sessionId }, async () => {
await streamText({ model, prompt });
});Application errors — sendException()
For errors outside a traced function (top-level handlers, background jobs) that you want to surface as their own span:
import { sendException } from "@runotis/sdk";
try {
await riskyBackgroundJob(userId);
} catch (err) {
sendException(err, {
userId,
metadata: { route: "/api/jobs/rebuild", jobId },
});
}Creates an otis.exception span with error.type, error.message, error.stack. If called inside a traced() scope, the span nests under the active parent; otherwise it's a root span.
Errors thrown inside a traced() function are already recorded automatically on that function's span; you don't need sendException in that case.