OtisDocs

SDK Reference

Privacy

PII redaction and identifier hashing

Otis aims to never hold Personally Identifiable Information (PII), since it has little to no value in product analytics.

Otis applies two independent privacy mechanisms, each with a client-side and a server-side layer:

  • PII redaction — removes PII from span text before it is stored.
    • Client-side runs in the SDK before spans are exported. Regex patterns catch structured PII and credentials (credit cards, emails, phone numbers, SSNs, API keys and vendor tokens, private keys, JWTs, etc.).
    • Server-side runs when Otis receives spans and before they touch persistent storage. A machine-learning model catches unstructured PII that regex can't detect (names, street addresses, and other PII that only context reveals). The client-side patterns run again here too.
  • Identifier hashing — pseudonymizes raw user, session, group and artifact IDs so raw values never reach analytics storage.
    • Client-side: the SDK replaces each ID with a keyed hash before it is exported, using a seed taken from your Ingest Key. Raw identifiers never travel over the wire. Set identifierHashKey if this layer has to stay opaque to Otis.
    • Server-side: when Otis receives a span, it hashes each ID again before storage, with a separate secret it holds for your project. Reversing a stored ID takes both secrets.

The layers are independent, so each one still protects your data when another misses something or is turned off.

Client-side PII redaction

Client-side redaction is enabled by default. Card numbers, US Social Security numbers, Canadian Social Insurance Numbers and UK National Insurance numbers are redacted only when they pass the number's own validity check. A run of digits that fails the check, such as a made-up card number, is sent as it is.

initOtis({
  serviceName: "my-app",
  piiRedaction: {
    enabled: true,                    // default
    disabledPatterns: ["IPV4"],       // skip specific patterns, by name
    customPatterns: [{                // add custom patterns
      name: "internal_id",
      regex: /INT-\d{10}/g,
      placeholder: "[INTERNAL_ID_N]",
      priority: 50,
    }],
  },
});

disabledPatterns matches a pattern's name exactly, and the built-in names are in upper case, such as IPV4, EMAIL and PHONE_NA. The exported DEFAULT_PII_PATTERNS lists every pattern with its name.

Placeholders

Each redacted value is replaced with a placeholder, {REDACTED_<TYPE>_<body>}, and the same value gets the same placeholder throughout a span.

With an Ingest Key, the body the SDK sends is a keyed hash of the value, written in lowercase letters and digits. The key is derived from your Ingest Key. The same value therefore gets the same body in every span and every process, and the body can't be turned back into the value. An email keeps its shape: each part before the @ and the domain are hashed separately, and the separators between the parts are kept. A free-mail domain such as gmail.com is sent as it is.

Input:  "Contact tim.moreton@acme.com for help. CC tim.moreton@acme.com for the team."
Output: "Contact {REDACTED_EMAIL_g4dgzuezmjqcxreh.gucoybjq55ug7whq@evzggjzsnkdur7r5} for help. CC {REDACTED_EMAIL_g4dgzuezmjqcxreh.gucoybjq55ug7whq@evzggjzsnkdur7r5} for the team."

Without an Ingest Key (a test exporter, or disabled: true with no key set) the body is a per-span counter, {REDACTED_EMAIL_1}, and the other examples on this page use that shorter form. Before storage, Otis replaces each hash body with a per-span counter, so nothing stored links one span to another.

Earlier SDK versions wrote a counter in every placeholder, such as {REDACTED_EMAIL_1}. If a beforeSend hook or other code of yours matches placeholders by that form, also match {REDACTED_<TYPE>_ followed by letters and digits.

Which attributes are scanned

The SDK scans the attributes that carry AI prompts and responses, tool calls, feedback, exceptions, and user and session property values.

Scanned attributes: ai.prompt, ai.prompt.messages, ai.prompt.lastUserMessage, ai.prompt.system, ai.prompt.tools, ai.response, ai.response.text, ai.response.object, ai.response.reasoning, gen_ai.system_instructions, gen_ai.input.messages, gen_ai.prompt, gen_ai.prompt.messages, gen_ai.output.messages, gen_ai.response, gen_ai.response.text, gen_ai.completion, ai.tool.input, ai.tool.output, ai.response.tool_calls, gen_ai.tool.call.arguments, gen_ai.tool.call.result, ai.decision.input, ai.decision.answers, user_message, response_message

Scanned Claude attributes: anthropic.tool_result.content, which holds a tool's result, and anthropic.session.outcome_evaluation.explanation, which holds a grader's explanation of a session outcome.

Scanned feedback fields: The comment and expected attributes in sendFeedbackSignal are scanned. The comment is stored as feedback.comment.

Scanned exception fields: exception.message and exception.stacktrace from sendException. exception.type (and its error.type twin) is not scanned, so keep personal data out of your error type names. In a home-directory path in a stack trace, only the user name is redacted, so the frames stay readable (/Users/jsmith/app/x.ts → /Users/{REDACTED_USERNAME_1}/app/x.ts).

Scanned prefixes: traits.*, metadata.*, otis.metadata.*, properties.*, session_properties.*

You never write the metadata prefix yourself. The SDK adds it to whatever you pass as metadata, so metadata: { jobId } becomes otis.metadata.jobId on withContext, wrap, traced, sendException, and sendFeedbackSignal spans.

Every other attribute is sent unscanned. That covers top-level attributes on sendEvent calls, OpenTelemetry identifier attributes (user.id, session.id), structural fields (format, doc.id, tool.name), and the artifact.id, artifact.stage and artifact.type attributes. To have a free-text value scanned without changing its stored name, mark its key with otis_redact_ (see Opt-in scanning).

MCP servers: captured tool input/output is outside this scanned set, so the MCP instrumentation applies this client-side redaction to it directly (on by default) and offers captureInput / captureOutput controls.

Credentials

Secrets that end up in a prompt, a tool result or a stack trace are redacted like PII. Most are replaced with a REDACTED_API_KEY placeholder. A JWT becomes REDACTED_JWT, a URL that embeds a password becomes REDACTED_URL, and an AWS access key ID that starts with AKIA becomes REDACTED_AWS_KEY.

  • Vendor tokens. Keys with a recognizable vendor prefix are redacted whole: AWS access key IDs, GitHub and GitLab tokens, OpenAI, Anthropic and OpenRouter keys, Google API keys and OAuth client secrets, Stripe secret and restricted keys, Slack tokens and webhook URLs, and tokens for npm, PyPI, SendGrid, Hugging Face, Databricks, Atlassian, Doppler, Linear, Shopify, Telegram bots and DigitalOcean.
  • Private keys. A PEM block from -----BEGIN … PRIVATE KEY----- to its END line is redacted whole. A block that was cut off before its END line is redacted to the end of the key material. Certificates and public keys are left alone.
  • Generic keys. A long token after a prefix such as sk_, api_, token_ or secret_ is redacted, as are JWTs and URLs that embed a user name and password.

The patterns are named VENDOR_SECRET and PRIVATE_KEY in disabledPatterns. For a secret format of your own, add a customPatterns entry.

Opt-in scanning with otis_redact_

Any attribute key with an otis_redact_ or sensitive_ segment prefix is value-scanned for PII, regardless of namespace. The match is case-insensitive and applies at any depth, and the . form works too (otis_redact.note).

otis_redact_ and sensitive_ are exact aliases. Prefer otis_redact_ in new code — it names the instruction rather than classifying the data. sensitive_ keeps working indefinitely.

sendEvent("document.export", {
  format: "pdf",                                   // not scanned
  sensitive_note: "Drafted with alice@acme.com",   // scanned → stored as `note`
});

sendArtifactEvent("doc-abc123", {
  stage: "share",
  sensitive_note: "Sent to alice@acme.com",        // scanned → stored as `artifact.note`
});
KeyScanned
sensitive_noteyes
sensitive.noteyes
artifact.sensitive_noteyes
hello.world.sensitive_emailyes
Foo.SENSITIVE.baryes (case-insensitive)
otis-redact-noteyes (hyphen is a separator)
otis_redact.noteyes (trailing . is fine)
nonsensitive_thingno (no segment boundary)
my-sensitive-noteno (no segment boundary — a hyphen doesn't create one)
email.sensitiveno (prefix the parent, not the leaf)
otis.redact.noteno — inside otis_redact the separator must be _ or -
sensitiveNoteno — camelCase is not a marker

Otis applies the same rule when it receives a span, so the server-side model also checks a marked value.

The marker needs a separator, and inside otis_redact it must be _ or -. Write otis_redact_note, otis-redact-note, otis_redact.note or sensitive.note. otis.redact.note is not a marker: a . there reads as a path segment, so the field gets no protection.

camelCase is never a marker, even though property key dropping does match camelCase names. sensitiveNote and otisRedactNote are ordinary field names. A field of yours called sensitiveContent or sensitiveData therefore keeps its name.

Otis removes the marker from the stored key. While server-side redaction is on, the value is scanned and then stored under its plain name: sensitive_note as note, and artifact.sensitive_note as artifact.note. Query the plain name. If the plain name is already on the same span, the marked key keeps its name, so that neither value is overwritten.

A marked key that names a PII field is still dropped. Otis drops a property key whose name reveals PII (email, ssn, first_name, …), and the marker doesn't change that: otis_redact_email and sensitive_email are dropped exactly like email. Use the marker to protect a value under a key that doesn't name a PII field, such as otis_redact_note. To keep a PII-named key, use the opt-out below.

Opting out with otis_dont_redact_

Any attribute key with an otis_dont_redact_ (or .otis_dont_redact.) segment prefix is exempt from everything — key-based dropping, the client-side regex, the server-side regex, and the server-side model. The value is stored exactly as you sent it, and the marker is stripped from the key before storage.

setUserProperties({
  email: "ada@acme.com",                    // dropped — the key names a PII field
  otis_dont_redact_email: "ada@acme.com",   // kept verbatim → stored as `email`
  otis_dont_redact_zip: "94114",            // kept verbatim → stored as `zip`
});

The marker matches as the opt-in does: case-insensitive, at any depth, and only at the start of the key or right after a .. So my_otis_dont_redact_note does not match, and neither does the camelCase otisDontRedactNote. Inside otis_dont_redact the separator must be _ or - (otis-dont-redact-email works, otis.dont.redact.email does not); a trailing . is fine (otis_dont_redact.email).

otis.dont.redact.email is not the opt-out, and for a PII-named field it does the opposite. With dots inside the token it is read as an ordinary key called email, so it is dropped by name — the outcome the marker exists to prevent. Use _ or - inside the token.

Two behavior changes on upgrade.

The opt-out now honors hyphens. otis-dont-redact-<field> did not previously register, and for a field whose name Otis drops by default it produced the opposite of what it asked for: properties.otis-dont-redact-email was dropped entirely. It is now honored, so a value you were losing will start being stored verbatim — which is what the marker asks for, but check that the fields you spell this way are ones you intend to keep in the clear.

The opt-in now honors hyphens too, which can capture an existing field. A key like properties.sensitive-data or properties.sensitive-content was previously an ordinary attribute; it now reads as the marker plus a field name, so its value is scanned and it is stored under the shortened name — properties.data, properties.content. Queries and dashboards on properties.sensitive-* will stop matching, with no error. (sensitive_data has always behaved this way; the change extends it to the hyphenated spelling.) If you have a field genuinely named sensitive-<something>, rename it or use otis_dont_redact_ on it.

This transfers responsibility for the field to you. The value is written to storage exactly as sent, with no key-drop, no regex and no model. Never put it on a field that can carry a natural person's data.

When to use it. Redaction is irreversible and happens before storage, and both ways of removing data sometimes remove data that isn't personal. Key dropping matches a fixed list of well-known names, so a legitimate address field meaning a wallet address, or a zip meaning a compression format, is dropped by name. Value scanning sometimes mistakes ordinary text for PII.

Use the narrowest thing that works: prefer renaming the field (wallet_address rather than address), then the opt-out on a single attribute. Avoid applying it across a whole namespace.

KeyResult
otis_dont_redact_zipkept verbatim → stored as zip
otis_dont_redact_emailkept verbatim → stored as email (key-drop bypassed)
otis_dont_redact.notekept verbatim → stored as note
OTIS_DONT_REDACT_notekept (case-insensitive)
my_otis_dont_redact_notenot matched — normal rules apply

Property key dropping

Property keys set via setUserProperties and identifyUser are checked against a curated list of well-known PII field names (e.g. ssn, email, first_name, phone_number, date_of_birth, credit_card, address). Matching entries are dropped entirely, since the key name itself reveals PII intent even if the value is redacted.

Hyphens are normalized to underscores, so first-name matches first_name. camelCase is matched too, so firstName, emailAddress, phoneNumber, dateOfBirth, creditCard, accountNumber and ipAddress are all dropped by the same entries as their snake_case spellings. Suffix matching catches compound keys like customer_ssn or primary_email.

camelCase matching arrived in a later SDK version. If you were relying on a property like firstName or accountNumber reaching Otis — for example as an opaque join key rather than as personal data — it will start being dropped on upgrade. Rename it, or use the otis_dont_redact_ opt-out to keep it.

camelCase is matched as a whole key only: firstName is dropped, but customer_firstName is not (its snake_case spelling customer_first_name is, via suffix matching). Spell compound keys consistently — all snake_case or all camelCase — rather than mixing the two in one name.

Group properties are governed server-side

setGroupProperties describes an account or company, not the individual person that PII redaction protects. The SDK therefore sends group property values without dropping keys or scanning values, and Otis decides when it receives them. The Redact group properties switch on the Redaction tab in your project's settings sets whether Otis redacts them (see Group properties). The otis_redact_ opt-in still applies client-side to any group property value you explicitly mark.

initOtis({
  serviceName: "my-app",
  piiRedaction: {
    dropPIIPropertyKeys: true,                   // default: true
    additionalPIIPropertyKeys: ["employee_id"],  // merge with defaults
  },
});

Property values not dropped by key matching are still scanned. For example, properties.notes = "Call 415-555-1234" redacts the phone number.

Use debug: "filter" to see which keys are dropped:

[otis:filter] { piiKeyDropped: 'properties.email' }

Override scanned attributes

scanAttributes and scanAttributePrefixes replace the defaults when provided:

initOtis({
  serviceName: "my-app",
  piiRedaction: {
    scanAttributes: ["custom.user_input", "custom.response"],
    scanAttributePrefixes: ["user_data."],
  },
});

To extend defaults rather than replace, spread the exported constants:

import {
  initOtis,
  DEFAULT_SCAN_ATTRIBUTES,
  DEFAULT_SCAN_ATTRIBUTE_PREFIXES,
} from "@runotis/sdk";

initOtis({
  serviceName: "my-app",
  piiRedaction: {
    scanAttributes: [...DEFAULT_SCAN_ATTRIBUTES, "custom.user_input"],
    scanAttributePrefixes: [...DEFAULT_SCAN_ATTRIBUTE_PREFIXES, "user_data."],
  },
});

Server-side PII redaction

Server-side redaction is enabled by default in every project environment. This second pass runs when Otis receives a span, before the span is stored. It uses a machine-learning model to detect unstructured PII that regex patterns can't reliably catch, such as personal names and street addresses. The server-side pass scans every attribute the client-side layer scans. It also scans the content other instrumentations write under their own keys, such as OpenInference's input.value and output.value and Google ADK's and Mastra's content attributes, and any attribute your project adds to the Attributes list on the Redaction tab in your project's settings.

What it adds on top of client-side redaction

  • Names and addresses. Personal names and street addresses that don't match any regex pattern.
  • One placeholder across attributes. The same person's name in user_message and in ai.response.text on one span gets the same placeholder.
  • Text around the SDK's placeholders. Where the SDK has already redacted part of a string (for example, replaced an email with {REDACTED_EMAIL_1}), the server-side pass still reads the text around the placeholder and can catch PII the SDK missed.
  • The SDK's patterns, again. The regex patterns the SDK uses also run on the server, so spans from an app with client-side redaction turned off still get them.

Configuration

Server-side redaction is configured per project environment from the Redaction tab in your project's settings in the Otis app.

SettingDefaultEffect
Redact PII using AIOnRuns server-side redaction on every span in this environment
Redaction levelBalancedAggressive redacts the most, including lower-confidence matches, and drops a span if the model fails on it. Balanced redacts confident matches. Permissive redacts only high-confidence matches, so less ordinary text is redacted by mistake. Balanced and Permissive keep a span with only regex-based redaction if the model fails on it
Redact group propertiesOff in a new projectRedacts setGroupProperties values like user properties. Leave it off to keep account firmographics (name, plan, …) that aren't personal data for a B2B account

At each of the three levels, the model leaves usernames and organization names alone. A username or an organization name in scanned text is therefore stored as sent, unless one of the regex patterns catches it.

Group properties

Group properties (setGroupProperties) describe an account, not a person. The firmographics a B2B product attaches to an account (company name, plan, seat count, industry) are usually not personal data, so a new project stores them as sent. Turn on Redact group properties to redact them like user properties.

A group property value whose key carries the otis_redact_ marker is scanned whatever the switch says. Mark the occasional field that does carry an end user's data, such as otis_redact_account_owner, to keep it protected.

Turning server-side redaction off is supported but not recommended. Client-side redaction alone will not catch unstructured PII like names or addresses.

Behavior on failure

If the server-side model is unavailable or fails on a span, the regex patterns still run, so structured PII (credit cards, SSNs, emails, phone numbers, etc.) is still caught. Unstructured PII such as names and addresses may not be. The redaction level decides what happens to such a span. Balanced and Permissive keep it with only regex-based redaction, accepting some risk of PII reaching storage in exchange for no data loss. Aggressive drops it.

Keep client-side on too

Even with server-side redaction enabled, keep client-side redaction on. Client-side catches structured PII before it leaves your infrastructure, which reduces your exposure in the event of a transport-layer issue or misconfiguration.

System prompts and tool definitions

The SDK sends the system prompt of every AI call it wraps, as ai.prompt.system, so that Otis can read what your AI was told to do beside what it did. Tool definitions (each tool's name, description and input schema) are sent as ai.prompt.tools only when you ask for them. Both go through client-side and server-side PII redaction like any other prompt content.

initOtis({
  serviceName: "my-app",
  captureSystemPrompt: false,    // send only a hash and the size of the system prompt
  captureToolDefinitions: true,  // also send tool definitions (default: false)
});

With either switch off, the SDK still sends a SHA-256 hash of the text and its size in bytes (ai.artifact.system.hash, ai.artifact.system.bytes, ai.artifact.tools.hash, ai.artifact.tools.bytes). A hash does not carry the text. With captureSystemPrompt: false the SDK also leaves system- and developer-role messages out of the message lists it records. A Vercel AI SDK call or agent with experimental_telemetry: { recordInputs: false } sends the hashes and sizes and neither text, whatever the switches say.

For the Claude Agent SDK, the tool definitions sent are those of in-process MCP servers built with createSdkMcpServer. Built-in tools and servers in other processes are never read.

Text longer than 60 KiB is cut, and the span is marked ai.artifact.system.textTruncated or ai.artifact.tools.textTruncated. Tool definitions are cut by leaving whole definitions out, and ai.artifact.tools.textOmitted counts them. The hash always covers the whole text.

Two more hashes carry no text either: ai.artifact.model.hash, a hash of the span's ai.model.id, and ai.artifact.run.hash, on the root span of an . An agent run is one execution of an agent, from the input it receives to its final output. The run hash combines the hashes of the system prompts, tool definitions, models and MCP tool lists that the agent run used, and ai.artifact.run.surfaces lists those hashes.

If you upgrade from an earlier SDK version, the Vercel AI SDK and OpenAI Chat Completions wrappers start sending the system prompt. Earlier versions sent it from the Anthropic and OpenAI Responses wrappers, and from the Vercel AI SDK and OpenAI Chat Completions wrappers only with captureArtifactText: true. captureArtifactText still works: it sets whichever of the two switches you have not set.

Reasoning

By default the SDK sends how many tokens a model spent reasoning, and none of the reasoning itself. The OpenAI, OpenRouter, Anthropic and Vercel AI SDK wrappers send the count when the provider reports one. To send the reasoning text too, turn on captureReasoning:

initOtis({
  serviceName: "my-app",
  captureReasoning: true,  // send reasoning text (default: false)
});

With it on, the SDK sends the reasoning a provider returns as ai.response.reasoning, on the span of the AI call that produced it: Anthropic thinking blocks; the OpenAI Responses API's reasoning summaries, and its reasoning text where a model returns it; the reasoning OpenRouter returns, on its Responses and chat APIs; and the reasoning parts of a Vercel AI SDK call. It goes through client-side and server-side PII redaction like the response text. Reasoning longer than 64 KiB is cut.

The Claude Agent SDK wrapper sends neither reasoning text nor a reasoning-token count, whatever this setting says. The CLI integration reads only the messages the agent wrote, never its reasoning.

With it off, the SDK sends no reasoning text, and it also removes reasoning from the message history it records: a thinking block your app sends back to Anthropic in a tool loop, a reasoning part kept in Vercel AI SDK messages, a reasoning item sent back to the Responses API, and the reasoning fields OpenRouter puts on chat messages.

A reasoning block the provider returns redacted, such as Anthropic's redacted_thinking, is never sent. The span carries only how many there were, as ai.response.reasoning.redacted_blocks.

The setting also covers spans the Vercel AI SDK's own telemetry records, when you send them to Otis through OtisSpanProcessor or OtisExporter. With it off, the SDK removes their reasoning attribute, and the reasoning parts in their message lists, before the span is sent. A message list the SDK cannot rewrite safely, such as one another library already cut short, is sent as it is, and PII redaction scans it like any other message. With it on, the SDK keeps each AI call's reasoning once, on that call's span.

Prompt-injection attempts

A prompt injection is text that tries to give an AI model new orders, such as "ignore all previous instructions" or a fake <system> tag. Otis reads your users' messages in order to analyze them, so Otis checks for these attempts when it receives a span, before anything is stored. The check runs on the server for every project, whether or not PII redaction is turned on.

What happens to a matching span

Otis keeps the span and removes its content. The span keeps its timing, model, token counts, cost, and session and user IDs, so your usage and cost figures are unaffected. The prompt, response and tool content on that span are removed, including the parts that did not match. A later span in the same conversation is treated the same way if its message history still contains the matching message.

Otis then marks the span with these attributes, which are stored with the span like any other span attribute:

AttributeMeaning
otis.prompt_injection.patternsThe names of the patterns that matched, such as ignore_previous_instructions or system_tag_injection
otis.prompt_injection.actionblanked when the content was removed. flagged when the span was only marked
otis.prompt_injection.matched_attributesWhich span attributes the match was found in
otis.prompt_injection.blanked_attributesHow many content attributes were removed from the span

The spans that carry otis.prompt_injection.patterns show how often your users attempt this, and which users and sessions are involved.

What is checked

Otis checks content that an end user or a tool could have written: prompts and message histories, responses, tool inputs and outputs, feedback comments, and exception messages.

Otis does not check content your own team wrote. A system prompt often contains the same phrases and tags an injection uses, so these are left alone:

  • ai.prompt.system and ai.prompt.tools
  • Messages with the role system or developer at the top level of a message array
  • The content of a gen_ai.system.message event

A prompt sent as one flat string is a special case. Otis cannot tell your template from your user's words inside it, so a match there only marks the span as flagged and removes nothing. This applies to ai.prompt and gen_ai.prompt when they hold plain text. To have user text checked in full, send it where its author is clear: as a user message in a message array, or in ai.prompt.lastUserMessage.

An attribute marked with otis_dont_redact_ is stored as sent and is not checked.

Limits of the check

The check recognizes common, known phrasings. It does not catch every attempt, and it can occasionally match ordinary text, such as a pasted log line that starts with System:. Keep your own model's safeguards in place, and send an attribute with otis_dont_redact_ if a field must always be stored as sent.

Identifier hashing

All identifiers (user IDs, session IDs, group IDs and artifact IDs) are pseudonymized with a keyed hash before they leave your application. This lets you correlate activity for analytics without exposing raw identifiers (emails, internal user IDs, etc.) in analytics storage.

Enabled by default

Identifier hashing is enabled by default. The hash's seed comes from your Ingest Key, so no additional configuration is needed.

initOtis({
  serviceName: "my-app",
  // Identifier hashing is enabled by default.
  // To disable (not recommended): identifierHashing: false
});

The SDK hashes an ID where your code passes it in (contextFromChatRequest, withContext, the context option of wrap, identifyUser), so everything downstream of that point sees the hashed value.

Identities stay the same across Ingest Keys

Each environment (production, development) has one identity seed, and every Ingest Key carries it:

sk-otis-K7mQ2xVb9TnR4wYc8LpZ3a-h5Gd1SfJ6uNe0BkXoW2tMq
└─────┘ └────────────────────┘ └────────────────────┘
 scheme          seed                random part

The first key you create for an environment fixes the seed, and every key created after it carries the same one. Rotating a key, or giving each service its own, therefore does not change who your users are: one person hashes to one ID under every key of the environment. Only the random part differs between keys, and it is what authenticates.

Keys in this format need @runotis/sdk 0.8.0 or later in every service that uses them, including the browser bundle. An older SDK does not know the format: it reports the same people under IDs of its own, which change when it is upgraded.

Replacing a key created before identity seeds

An older key, sk-otis-{project id}-{48 hex characters}, has no seed, so Otis cannot tell what identities it produces. It keeps working. When you create its replacement in Settings → Ingestion, the dialog asks for the key you are replacing. Paste it, and the new key continues its users, groups and sessions.

The dialog offers this once per environment. If you create a key without pasting one, later keys continue that new key instead, and the only way to keep the old identities is identifierHashKey below. A key revoked more than 30 days ago can no longer be pasted either.

Hashing with a secret of your own

identifierHashKey replaces the Ingest Key as the source of the seed:

initOtis({
  serviceName: "my-app",
  apiKey: process.env.OTIS_API_KEY,
  identifierHashKey: process.env.OTIS_IDENTIFIER_HASH_KEY,
});

It has two uses:

  • Keep the identities of an earlier key. Set it to that key, even one you have revoked, and identifiers hash as they did under it whatever key you authenticate with.
  • Keep the seed away from Otis. Set it to a long random secret that you generate and hold. Otis never receives it, so the client-side hash stays a boundary Otis cannot cross. See Server-side hashing layer.

Every instance that should agree on who a user is must carry the same value: your server, your browser bundle, each service. An instance without it hashes with its Ingest Key and reports the same people under different IDs. Server runtimes read OTIS_IDENTIFIER_HASH_KEY when the option is not passed; browsers have no environment, so pass it explicitly there.

A wrong value here is not caught by anything downstream: your Ingest Key still authenticates, spans still arrive, and every user is reported under a new ID. So initOtis throws if identifierHashKey starts like an Otis key but is not a complete one, which catches a truncated paste or a key copied with its quotes. It cannot catch a single wrong character. Where the SDK is disabled, or has no Ingest Key, it sends nothing, so the same fault is only a warning.

Upgrading from an SDK before 0.8.0

Earlier SDKs hashed client-side only when the key was passed as the apiKey option. An SDK that found its key in the OTIS_API_KEY environment variable sent raw identifiers and relied on the server-side hash alone. From 0.8.0 both hash the same way.

If your server is configured through OTIS_API_KEY with no apiKey option, upgrading changes the user, group, session and artifact IDs it reports, once. To keep the existing IDs instead, set identifierHashing: false, which is what that configuration was doing. Installations that pass apiKey are unaffected.

What gets hashed

InputOutput prefix
User IDusr_v1_<hash>
Session IDses_v1_<hash>
Group IDgrp_v1_<hash>
Artifact IDart_v1_<hash>

Helpers to check whether a value is already hashed

import { isHashedUserId, isHashedSessionId, isHashedGroupId, isHashedArtifactId } from "@runotis/sdk";

Already-hashed values pass through without re-hashing, so it's safe to call identifyUser(hashedId) in code paths where the ID might have already been hashed upstream.

A value counts as already-hashed when it carries the prefix and a 43-character base64url body — the shape the SDK itself produces. A prefixed value with any other body is treated as plaintext and hashed. Passing a hand-constructed usr_v1_… of your own devising therefore hashes it rather than passing it through; use the SDK's own output, or isHashedUserId to check first.

Hand-built OpenTelemetry spans

Hashing is applied at the SDK's own entry points. A span you build directly on an OTel tracer doesn't pass through them, so stamping user.id or session.id onto it yourself would emit a different value than the rest of your spans carry — the same person would appear as two users.

Apply the same processing explicitly:

import { processUserId, processSessionId } from "@runotis/sdk";

const span = tracer.startSpan("checkout.complete", { startTime });
span.setAttribute("user.id", processUserId(userId));
span.setAttribute("session.id", processSessionId(sessionId));

Both are idempotent, return undefined for undefined, and leave the value unchanged when identifier hashing is disabled — so they're safe to apply unconditionally. processArtifactId and the processUserIdAsync / processSessionIdAsync variants are also exported; prefer the async ones in browsers where the call site can await.

processArtifactId additionally trims and lowercases the id before hashing, so Deck_A1 and deck_a1 are the same artifact. It does this whether or not hashing is enabled. User and session ids are not case-folded — those are treated as opaque, and Alice and alice remain different users.

Anonymous users

With browser: { autoAnonymousUserId: true } in initOtis, the browser SDK gives a visitor who hasn't signed in a user ID prefixed anon_, and keeps it in a cookie. Browser and consent describes the cookie and the consent it needs. The SDK hashes an anonymous ID like any other user ID, so the value that reaches Otis carries the usr_v1_ prefix. When an anonymous visitor later signs in, identifyUser switches later activity to the signed-in ID. Otis doesn't merge the earlier anonymous activity into that user.

Server-side hashing layer

When Otis receives a span, it hashes each ID a second time with a secret it holds for your project. You don't need to configure this layer. Your app never handles the second hash. The IDs Otis stores carry usr_v2_, ses_v2_, grp_v2_ and art_v2_ in place of the _v1_ prefixes, so a stored ID never equals the value your app produced.

What the second hash protects

Someone who knows the client-side seed could hash a list of likely IDs, such as every email address at a company or every sequential internal ID, and compare the results with stored IDs. The second hash stops this. Reversing a stored ID takes both the client-side seed and the server-side secret, and neither is kept in analytics storage.

Who holds the client-side seed

By default Otis does. So that a new key can keep your users' identities, Otis records each environment's seed alongside the server-side secret, in its configuration store. The double hash therefore protects identifiers against a leak of analytics storage, and against anyone who holds only one of the two secrets. It does not protect them against a breach of Otis's configuration store together with analytics storage.

If you need the client-side hash to be a boundary Otis itself cannot cross, set identifierHashKey to a secret you generate and hold, and keep your Ingest Key server-side. Otis then never has the first of the two.

A key created before identity seeds, and not yet replaced, is as it was: Otis stores only a hash of it and cannot derive its seed.

Server-side keys only

A seed is only secret where the key that carries it is a server-side secret — a backend SDK, a serverless function, an edge worker. In a browser bundle the Ingest Key, or an identifierHashKey, is served to every visitor, and anyone can read the client-side seed from it.

For browser apps, treat client-side hashing as data minimization rather than as a second security boundary: raw emails and internal user IDs never travel over the wire, never enter request logs or proxy logs, and are never held in memory by Otis. The pseudonymization that resists reversal is the server-side pass, keyed by a per-project secret Otis never transmits to clients.

If you need identifiers that stay opaque to Otis in a browser context, hash them in your own backend before passing them to the SDK. Already-hashed values pass through unchanged.

Memories and uploaded documents

Redaction and identifier hashing apply to the telemetry your app sends. What your team tells Otis in a chat, and the files it uploads, are stored as provided. Keep personal data out of both. Memories and knowledge base describes who can see them.

Data deletion and export

To delete a project, to delete one user's data, or to export your data, contact Otis.

For production use, the recommended settings are:

  • Leave client-side PII redaction on (the default).
  • Leave server-side PII redaction on (the default).
  • Leave identifier hashing enabled (the default).
  • Use the Balanced redaction level (the default) or Permissive for analytics environments where data loss is costly.
  • Use the Aggressive redaction level for regulated environments where PII exposure is costlier than missing spans.

On this page