@mainwave/sdk
v0.2.0
Published
Node.js SDK for Mainwave AI: OpenTelemetry tracing for AI agents and LLM applications.
Downloads
438
Readme
@mainwave/sdk
Node.js SDK for Mainwave AI: OpenTelemetry tracing for AI agents and LLM applications. It sends each run's spans, token counts and cost inputs to the Mainwave collector over OTLP/HTTP (protobuf, gzip).
Requires Node 22.13 or newer on 22.x, or 23.5 or newer.
@mainwave/sdk/eve requires Node 24 or newer, as eve itself does.
Install
npm install @mainwave/sdkThe package is ESM-only. @opentelemetry/api (>=1.9.0 <1.10.0) is a required peer dependency. The framework and provider packages are optional peers, installed by the application: eve, ai with @ai-sdk/otel, openai, @anthropic-ai/sdk.
Pick your path
| The application is | Read |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| an eve agent (deployed on Vercel or run anywhere eve runs) | eve on Vercel |
| a Node application that owns its process (server, worker, script, serverless function) | Owned mode, then Manual runs and spans |
| a host that already registers OpenTelemetry (@vercel/otel, NodeSDK, NodeTracerProvider) | Hosts with their own OpenTelemetry |
| calling the Vercel AI SDK v7 directly | Vercel AI SDK v7 |
| calling openai or @anthropic-ai/sdk directly | OpenAI, Anthropic |
Every path needs MAINWAVE_API_KEY. Confirm that spans arrive with Verify a span arrived.
eve on Vercel
Supported: eve >=0.66.3, an optional peer dependency. The function runs Node 24 or newer.
Add one file to the agent, for example agent/instrumentation/mainwave.ts:
import { mainwave } from '@mainwave/sdk/eve'
export default mainwave()and set MAINWAVE_API_KEY in the deployment's environment (on Vercel, under the project's Environment Variables). Name the use case with mainwave({ useCaseKey: 'support-agent' }) or MAINWAVE_USE_CASE_KEY; see Use-case identity. The @vercel/otel that eve embeds registers a flush with the request's waitUntil for each trace, and the Mainwave processor drains on it, so an eve app adds no flush() call. eve owns the process's OpenTelemetry tracer provider, so mainwave() adds the Mainwave span processor to eve's pipeline and registers nothing itself. Do not call init() in an eve app. mainwave(options) takes the same options as init(), except mode, plus runAttributes and modelAliases (both below) and exportPolicy: eve's export policy for this destination, which eve applies to every span before the Mainwave processor sees it, as on any otelIntegration(), except that an attribute decision does not apply to the SDK's mainwave.* routing keys (schema version, run id, use case, frameworks): they carry no content, and Mainwave needs them to group the spans into runs and use cases. Declare mainwave() in one instrumentation file. A second call logs one warning and adds no span processor; its runAttributes and modelAliases still apply, its exportPolicy does not, so put the export policy on the first declaration.
What each eve turn becomes in Mainwave:
- One run per turn, named
mainwave.run: eve's turn root is renamed, whatever its name. - A root eve left without a status takes the turn's outcome:
completedandcancelledare OK,failedis an error.agent.terminated_reasonis set tosuccess,cancelledorerrorunless your code already set it. - Input tokens are reported without the cache writes, which eve counts in its input figure and reports again on their own.
- An error span with no message takes eve's retry reason, else its
error.type(unless_OTHER), as its message. - eve's
ai.*copies of prompts and responses are dropped where the same content is on the span'sgen_ai.*keys. - A model call's provider is reported under its OpenTelemetry GenAI name: eve copies the AI SDK model's provider string (
amazon-bedrock,anthropic.messages,openai.responses,google.generative-ai), and Mainwave sendsaws.bedrock,anthropic,openai,gcp.gemini. A provider string outside the table of@ai-sdk/otel(gateway, for one) is sent as eve reports it. - Every span sent to Mainwave carries these resource attributes:
mainwave.sdk.language(nodejs),mainwave.sdk.version(this package's version),telemetry.sdk.name(opentelemetry) andtelemetry.sdk.language(nodejs), so Mainwave shows the run's sender as the Node SDK. eve owns the resource, so the SDK adds them to the spans it sends, under eve's own attributes: a value set through eve'sotel({ resource })is sent as set.telemetry.sdk.versionis not added: eve runs the OpenTelemetry SDK built into its own bundle and does not expose that version; set it throughotel({ resource })if you need it. eve's resource itself is not changed, and other destinations receive it without these keys. - Content follows eve's trace policy and the
exportPolicypassed tomainwave(): what eve does not record, or the policy drops or redacts, never reaches Mainwave. In production, eve records no content for private conversations by default; such runs show no input, output or reasoning.
The SDK cannot add content eve did not record: captureContent can only remove content from what eve sends, never restore it. To see message content in production, change eve's trace policy (tracePolicy); the SDK does not change it.
Labelling outcomes while content is withheld
runAttributes is called once for each turn as it is exported, with the turn's id, sequence, session id, agent name, outcome and the attributes Mainwave exports for it. Return labels for the turn, or undefined:
import { mainwave } from '@mainwave/sdk/eve'
export default mainwave({
runAttributes: (turn) =>
turn.outcome === 'completed' ? { output: 'resolved', decision: 'answered' } : undefined,
})| Field | Recorded as |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| output | the run's output |
| decision | the run's decision |
| reason | the run's termination reason (replaces the one derived from the outcome) |
| iterations | the run's iteration count (integers only) |
| cost | the run's reported cost (finite numbers only) |
| metadata | run metadata, one entry per own enumerable field (strings, numbers, booleans); a getter on a metadata class is not read |
The values are sent as given: return labels such as resolved or escalated, never message text, names, email addresses or other user identifiers. The SDK cannot check this. Strings longer than 8,192 characters are cut. Other fields and values of other types are dropped. The hook must return synchronously; if it throws or returns something other than an object, the turn is exported without its labels and one warning is logged.
Model names
eve reports the model id the provider was called with. For a Bedrock application inference profile that is the profile's ARN. modelAliases renames a model at export, on the span's gen_ai.request.model and gen_ai.response.model, in the chat <model> span name and in its resource.name attribute:
import { mainwave } from '@mainwave/sdk/eve'
export default mainwave({
modelAliases: {
'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123':
'us.example.model-1',
},
})The rename does not reach eve's agent.step span: it keeps the id eve reports in agent.model.id and the provider string in agent.model.provider. An application that must not export either, for example an ARN that carries an account id, adds an exportPolicy attribute decision that drops or replaces that key; agent.model.id is not one of the exempt mainwave.* routing keys.
A key must equal the whole model string exactly, including case; there are no prefixes or patterns. A model that matches no key is sent as eve reports it, and nothing is renamed without the option. The SDK does not work out a model id from an ARN. An entry whose key or value is not a non-empty string, or whose value is also a key, is skipped, and one warning gives the number skipped; an option that is not an object is ignored with one warning. mainwave() never throws inside eve.
Moving from your own exporter
If the agent already sends eve's spans to Mainwave through its own exporter or span processor:
- Remove that exporter, and any span processor that renames eve's root span, from
agent/instrumentation/. With both in place, every span arrives twice. - Add the
mainwave()file above. - Keep
otel({ resource: { 'mainwave.ucid': … } })as it is, or pass the same use-case id asmainwave({ useCaseId }); either keeps the runs in the same use case. - If the removed destination had an
exportPolicy, pass the same policy asmainwave({ exportPolicy }). eve applies an export policy only to the destination that declares it, so a policy left in the removed file no longer applies, and the content it redacted starts reaching Mainwave. As with any policy passed tomainwave(), its attribute decisions do not apply to the SDK'smainwave.*routing keys. - Keep your
tracePolicyunchanged; the SDK does not change what eve records. - Set
MAINWAVE_API_KEY. - Confirm
eveis 0.66.3 or later, and that the function runs Node 24 or newer. - If your own exporter rewrote the model id eve reports, for example an inference-profile ARN to the model's id, pass the same mapping as
mainwave({ modelAliases })(see Model names). If it rewrote the provider name (amazon-bedrocktoaws.bedrock), remove that rewrite: the SDK does it.
To confirm the move, open the use case in Mainwave: each eve turn appears as one run with an OK or error status, and no span appears twice.
Without an API key, or with another configuration error, mainwave() logs one error and returns a destination with no processor: eve runs normally and nothing is sent to Mainwave.
Owned mode quickstart
Owned mode is for an application that has no OpenTelemetry setup of its own. init() builds a tracer provider, registers it with an AsyncLocalStorage context manager and the W3C propagator, and exports every LLM trace to Mainwave.
import OpenAI from 'openai'
import * as openaiModule from 'openai'
import { flush, init, instrument, run } from '@mainwave/sdk'
init({ useCaseKey: 'invoice-workflow' })
instrument({ openai: openaiModule })
const client = new OpenAI()
await run('INV-12345', { metadata: { customer: 'acme' } }, async () => {
await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Summarize invoice INV-12345.' }],
})
})
await flush()init()readsMAINWAVE_API_KEYwhenapiKeyis not passed and throwsMainwaveConfigErrorwhen neither is set, or whenuseCaseId(orMAINWAVE_USE_CASE_ID) is not a UUID. Call it once, at process start, before the firstrun(). A second call with an equal configuration does nothing; a different one throwsMainwaveReinitConflict.instrument({ openai })attaches the OpenAI instrumentation to the imported module; OpenAI and Anthropic list the other attach paths. The Vercel AI SDK takesregisterTelemetryinstead, see Vercel AI SDK v7.run()is optional. Without it, each trace is its own run in Mainwave. Wrap a unit of work inrun()when it spans several traces.await flush()sends what is buffered and resolvestruewhen the collector accepted it; see Flush and serverless.init({ mode })is'auto'by default: owned mode, unless another OpenTelemetry tracer provider is already registered in the process. Then the SDK switches to host mode, exports nothing and logs one warning until the host is wired as in Hosts with their own OpenTelemetry.
init() options, each with its environment variable in Environment variables: apiKey, useCaseId, useCaseKey, ucidNamespace, collectorUrl, serviceName, deploymentEnvironment, disabled, captureContent, logLevel, mode, handleSignals, skipInstrumentations, redaction, mask (see Redacting sensitive data). An option wins over its variable. A value of the wrong type throws MainwaveConfigError naming the option, except logLevel, which applies warn and logs one warning. A useCaseId that is not the use case's UUID (a use-case key, a pasted API key, or a UUID with surrounding spaces) throws MainwaveConfigError; to name a use case, set useCaseKey.
Message and tool content is captured by default (captureContent: 'SPAN_ONLY'). captureContent: false removes every message, instruction, tool definition and reasoning body from the exported spans; model, token counts, finish reasons and errors still arrive. The setting is read on each export, so a later init({ captureContent }) takes effect without a restart. A value of OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT that is not true, false or a capture-mode name is treated as false, with no log line.
Manual runs and spans
import { bindIdentity, flush, init, run, span, startRun } from '@mainwave/sdk'
init({ useCaseKey: 'invoice-workflow' })
// A run around a block. The callback's return value is the run's.
const total = await run('INV-12345', { metadata: { customer: 'acme' } }, async (current) => {
current.setMetadata({ stage: 'extract' })
return span('extract-lines', { attributes: { 'invoice.pages': 3 } }, async (lineSpan) => {
lineSpan.setAttribute('invoice.lines', 14)
return 14
})
})
// A run you end yourself, for work that does not fit one callback.
const handle = startRun('INV-12346', { agentName: 'reviewer' })
try {
await handle.with(async () => {
// spans and LLM calls started here belong to the run
})
handle.end({ status: 'ok', decision: 'approved', iterations: 2 })
} catch (error) {
handle.recordError(error)
handle.end({ status: 'error', description: String(error) })
}
// Identity for a block without a run.
await bindIdentity({ useCaseKey: 'refund-workflow', agentName: 'refunds' }, async () => {})
await flush()
console.log(total)| Call | Behavior |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| run(runId, [options], fn) | Opens a mainwave.run span, runs fn(run) with the run bound across await, Promise.all and timers, ends the run OK when fn settles (unless fn ended it), and, when fn throws, records the error, ends the run as an error and rethrows. Resolves with what fn returns. |
| startRun(runId, [options]) | Returns a Run without changing the current context. Bind it for a block with run.with(fn). run.end() takes status, description, reason, decision, iterations, cost; only the first end() counts. A run that is garbage-collected without end() logs one warning and its span stays open. |
| span(name, [{ attributes }], fn) | A custom span; fn gets the OpenTelemetry Span. |
| bindIdentity({ useCaseId, useCaseKey, agentName }, fn) | Binds a use case and agent name to every span started inside fn. |
Two runs in flight never share a run id or identity, across await, Promise.all and setTimeout. run(), startRun(), span() and bindIdentity() called before init() run their callbacks without telemetry and log one warning. After init(), only invalid caller input, such as passing both useCaseId and useCaseKey, rejects or throws MainwaveConfigError; every other failure is logged and the callback still runs. A run() or span() in a host without a registered context manager logs one warning, and spans do not nest across await.
Hosts with their own OpenTelemetry
OpenTelemetry JavaScript offers no way to add a processor to a provider that already exists. When the application, @vercel/otel, NodeSDK or another library registers the tracer provider, pass createSpanProcessor() to it. The processor buffers spans per trace, drops traces with no LLM activity, normalizes attributes and exports to Mainwave. Calling it again with an equal configuration returns the same instance, so wiring it twice does not export twice.
The host's @opentelemetry/sdk-trace-base must be 2.0 or newer. A host on 1.x hands the processor span records it cannot read: the processor logs one warning and exports nothing. @vercel/otel 2.0 or newer requires sdk-trace-base 2.x as a peer dependency, so the application's installed copy decides the version, and @opentelemetry/sdk-node 0.200.0 or newer depends on 2.x; @vercel/otel 1.x and @opentelemetry/sdk-node 0.57.x or older use 1.x.
@vercel/otel (registerOTel), for example in Next.js instrumentation.ts:
import { registerOTel } from '@vercel/otel'
import { createSpanProcessor } from '@mainwave/sdk'
export function register(): void {
registerOTel({
serviceName: 'invoice-app',
spanProcessors: ['auto', createSpanProcessor({ useCaseKey: 'invoice-workflow' })],
})
}NodeSDK from @opentelemetry/sdk-node:
import { NodeSDK } from '@opentelemetry/sdk-node'
import { createSpanProcessor } from '@mainwave/sdk'
const sdk = new NodeSDK({
spanProcessors: [createSpanProcessor({ useCaseKey: 'invoice-workflow' })],
})
sdk.start()A plain NodeTracerProvider takes new NodeTracerProvider({ spanProcessors: [createSpanProcessor()] }).
createSpanProcessor(options)takes theinit()options exceptmode, and reads the same environment variables. It throwsMainwaveConfigErrorfor a missing API key or auseCaseIdthat is not a UUID;disabled: truereturns a processor that does nothing.- The host's context manager carries
run()andbindIdentity()acrossawait.NodeSDKandregisterOTelregister one. - With no
useCaseIdoruseCaseKeyconfigured, spans take a valid UUIDmainwave.ucidfrom the host's resource, if there is one. A configured use case that differs from the resource's is stamped on the span and logs one warning on the first such span. A resourcemainwave.ucidthat is not a UUID is ignored with one warning naming the key. init()is optional here. Callinit({ mode: 'host' })first to set options once for bothinit()and latercreateSpanProcessor()calls; a latercreateSpanProcessor()with different options throwsMainwaveReinitConflict.- An
init()inautoorownedmode that finds another provider registered, withoutcreateSpanProcessor()wired into it, exports nothing and logs one warning namingregisterOTel({ spanProcessors })andnew NodeSDK({ spanProcessors }). The SDK does not detect which host registered the provider. - On a host where eve is the provider, use
mainwave()from@mainwave/sdk/eveinstead; see eve on Vercel.
Vercel AI SDK v7
Supported: ai ^7 with @ai-sdk/otel ^1, both optional peer dependencies. AI SDK v5 and v6 are not supported.
import { registerTelemetry } from 'ai'
import { init } from '@mainwave/sdk'
import { mainwaveTelemetry } from '@mainwave/sdk/ai-sdk'
init({ useCaseKey: 'support-agent' })
registerTelemetry(mainwaveTelemetry())Register once, after init() (owned mode) or after wiring createSpanProcessor() into the host's provider. mainwaveTelemetry(options) is the OpenTelemetry integration of @ai-sdk/otel with usage: true; it takes the same options except usage. It writes spans through the global tracer provider; a host whose provider is not the global one passes { tracer }.
- Each
generateTextorstreamTextcall exports itsinvoke_agent, step,chat <model>andexecute_toolspans;embedexports anembeddings <model>span. The AI SDK's input token count includes cache writes; the SDK reports input and cache-write counts separately, and moves reasoning tokens to the OpenTelemetry GenAI key. - Model calls through
@ai-sdk/amazon-bedrockare reported with provideraws.bedrock. - In an eve app,
mainwaveTelemetry()returns an integration with no callbacks and logs one warning: eve already sends the AI SDK spans of its model calls, and a second integration would duplicate every chat span. - Importing
@mainwave/sdk/ai-sdkwithoutaior@ai-sdk/otelinstalled fails at import withERR_MODULE_NOT_FOUNDnaming the package. - If
@ai-sdk/otelrejects the options, the SDK logs one error and the AI SDK runs unchanged.
OpenAI
Supported: openai >=4.19.0 <8, an optional peer dependency.
The SDK traces Chat Completions, Responses and Embeddings calls, including the helpers built on them (chat.completions.parse(), chat.completions.stream(), runTools(), responses.parse(), responses.stream()), with the official OpenTelemetry instrumentation (@opentelemetry/instrumentation-openai) and what it does not record by itself: the messages, instructions and tool definitions on the span, the cached, cache-write and reasoning token counts, and an ended span for every failed, cut or abandoned stream. parse() and withResponse() return what they return without it.
Attach it in one of three ways. An ES module application that does neither of the first two produces no spans from openai and logs one not-attached warning naming both. A CommonJS application is covered by the first way without the flag, and by instrument() always.
Start Node with the SDK's loader hooks, before the application, and call init() in it as usual:
node --import @mainwave/sdk/register app.mjsThis covers static imports, dynamic import('openai') and require('openai'). A CommonJS application that calls init() before its first require('openai') needs no flag.
Or pass the module to instrument(), for bundled applications, serverless functions, or wherever the flag cannot be set. On Vercel Functions and in bundled applications, instrument() is the supported way; the flag is not documented there:
import * as openai from 'openai'
import { init, instrument } from '@mainwave/sdk'
init()
instrument({ openai })Pass every copy of openai the application loads: an ES module import and a require('openai') result are two different copies, and a copy that was not passed is not traced (instrument({ openai: [esm, required] })). instrument() returns { openai: 'patched' }, 'already' when the copy was attached before, or 'unrecognized' when what it was given is not the openai module. When the SDK is disabled it patches nothing and returns {}. It never throws.
Or, in a host that registers its own OpenTelemetry instrumentations, list the SDK's class:
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { OpenAIInstrumentation } from '@mainwave/sdk/openai'
registerInstrumentations({ instrumentations: [new OpenAIInstrumentation()] })ES module imports are then intercepted by the host's own OpenTelemetry loader, which the host registers before the application starts (module.register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) in a preload script). A copy attached by two of these ways is instrumented once.
When openai is installed and no copy of it was attached, the SDK logs one warning naming the flag and instrument(). init({ skipInstrumentations: ['openai'] }) turns the SDK's hooks off, for example when the host already runs another OpenAI instrumentation, which would otherwise trace every call twice; createSpanProcessor({ skipInstrumentations: ['openai'] }) silences the warning in a host that wires openai some other way. A version outside the supported range is not instrumented and logs one warning.
Content follows captureContent and OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; with content off no message, instruction or tool definition is sent, and model, token counts, finish reasons and errors still are. The instrumentation keeps the setting in effect when it first attaches: messages and tool definitions turned on after that stay off until the process restarts. A client pointed at another OpenAI-compatible service through baseURL is reported with the provider openai and that service's host and port.
Anthropic
Supported: @anthropic-ai/sdk >=0.51.0 <1, an optional peer dependency.
The SDK traces every Messages API call with its own OpenTelemetry instrumentation: messages.create(), with or without stream: true, and the helpers built on it (messages.stream(), messages.parse(), beta.messages.create(), beta.messages.stream(), each model turn of beta.messages.toolRunner()). Each call is one chat <model> span carrying the messages, system prompt and tool definitions, the response text, tool calls and thinking text, the finish reason, and the input, output, cache-read and cache-write token counts. A redacted thinking block is recorded as [redacted thinking], never as its encrypted payload. A stream ends its span when it finishes, when the loop over it breaks, or when it is aborted, with what arrived until then; an aborted call is a cancellation, not an error, even when the abort lands before the response or while a non-streamed response body is read (its span then carries the request only). A stream that is created and never read records nothing. countTokens(), batches, models and files make no span. What the client returns, streams or throws is unchanged.
Attach it the same three ways as OpenAI. node --import @mainwave/sdk/register app.mjs covers static imports, dynamic import('@anthropic-ai/sdk') and require('@anthropic-ai/sdk'), for openai and @anthropic-ai/sdk in one process alike; a CommonJS application that calls init() before its first require('@anthropic-ai/sdk') needs no flag. For bundled applications and serverless functions, pass the module:
import * as anthropic from '@anthropic-ai/sdk'
import { init, instrument } from '@mainwave/sdk'
init()
instrument({ anthropic })Pass every copy the application loads (instrument({ anthropic: [esm, required] })); instrument() returns { anthropic: 'patched' }, 'already' or 'unrecognized' and never throws. In a host that registers its own OpenTelemetry instrumentations, list the SDK's class, with the host's OpenTelemetry loader registered before the application for ES module imports:
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { AnthropicInstrumentation } from '@mainwave/sdk/anthropic'
registerInstrumentations({ instrumentations: [new AnthropicInstrumentation()] })A copy attached by two of these ways is instrumented once. When @anthropic-ai/sdk is installed and no copy of it was attached, the SDK logs one warning naming the flag and instrument(). init({ skipInstrumentations: ['anthropic'] }) turns the SDK's hooks off, for example when the host already runs another Anthropic instrumentation. A version outside the supported range is not instrumented and logs one warning.
Content follows captureContent and OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, read on every call; with content off no message, system prompt, tool definition or response text is sent, and model, token counts, finish reasons and errors still are.
Use-case identity
Mainwave groups runs into use cases. The use case of a span, innermost first:
| Level | Set by | Result |
| ----- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| 1 | useCaseId on run() or bindIdentity() | that UUID |
| 2 | useCaseKey on run() or bindIdentity() | a UUID derived from the key |
| 3 | useCaseId on init() or MAINWAVE_USE_CASE_ID; else useCaseKey on init() or MAINWAVE_USE_CASE_KEY | that UUID, or one derived from the key |
| HOST | Host mode, nothing at levels 1 to 3: a valid UUID mainwave.ucid on the host's resource | that UUID |
| 4 | agentName on a run, when no use case is configured and the host resource carries none | derived from <serviceName>/<agentName> |
| 5 | nothing configured | derived from serviceName |
A use-case id is a UUID created in the Mainwave app. A use-case key is a name you choose; the SDK derives the same UUID from the same key on every machine and restart. Renaming a key starts a new use case. Keys are case-sensitive and trimmed; control characters are stripped, and an empty key or one longer than 256 characters is invalid. Passing both useCaseId and useCaseKey as options throws MainwaveConfigError. A useCaseId passed to run(), startRun() or bindIdentity() that is not a UUID is ignored with one warning; spans keep the use case bound around the call, else the process use case. After an enabled init(), useCaseIdFor('invoice-workflow') returns the UUID a key derives to; before init(), or when the SDK is disabled, it throws MainwaveConfigError.
Derivation seeds on the API key unless ucidNamespace (or MAINWAVE_UCID_NAMESPACE) is set, so rotating the key changes every derived UUID. Without a namespace, the SDK logs one warning when the first span carries a derived UUID. Set the namespace issued to your organization in the Mainwave app to make derived identities survive key rotation. A key the platform has not seen appears as an unassigned use case; with MAINWAVE_LOG_LEVEL=info the SDK prints the link to assign it.
Redacting sensitive data
Redaction replaces text in a span before the span leaves your process. Without it, spans export exactly as the application made them. With it, only the export to Mainwave is redacted: a span processor or exporter you wired onto your own provider still receives the original span. Three tools, all opt-in: your own regular-expression rules, built-in rule packs and a masking function.
Give the SDK rules and packs in one of two ways. Both take the same object, whose field names follow the OpenTelemetry Collector's redactionprocessor:
- the
redactionoption ofinit(),createSpanProcessor()ormainwave(); MAINWAVE_REDACTION_FILE, the path of a JSON file holding the object.
The option wins; with it set, the file is not read, and if the variable is also set the SDK logs one warning that the file is ignored. The Python SDK reads the same file, so one file serves both. The masking function is an option only.
| Field | Meaning |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| packs | Built-in rule packs to turn on, from the table below. Default: none. |
| blocked_values | Value rules. Each is a pattern string or { name, pattern, case_insensitive }. Every match is replaced; the rest of the string is unchanged. |
| blocked_key_patterns | Rules searched in the attribute key. A hit replaces the whole value (each element of a string array). |
| allowed_values | Rules for matches to keep: a blocked_values or pack match that one of these matches in full is not replaced. |
| ignored_keys, ignored_key_patterns | Exact keys, and patterns searched in the key, whose values are never scanned. They add to the built-in exempt keys. |
| summary | info (default) or silent. With info, a span with at least one replacement carries mainwave.redaction.count, the number of replacements. |
A replaced value reads [REDACTED:<name>], or [REDACTED] when the rule has no name. On every scanned string the rules run in this order: pack rules, then blocked_values, then the masking function; a later step sees the output of an earlier one. A key rule runs first and leaves the value unscanned afterwards.
import { init } from '@mainwave/sdk'
init({
redaction: {
blocked_values: [
{ name: 'email', pattern: '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}' },
{ name: 'api_key', pattern: 'sk-[A-Za-z0-9]{20,}' },
],
allowed_values: ['support@example\\.com'],
},
})With these rules, the prompt Mail [email protected] or [email protected]. Key: sk-ABCDEFGHIJKLMNOPQRSTUV reaches Mainwave as Mail [REDACTED:email] or [email protected]. Key: [REDACTED:api_key], and the span carries mainwave.redaction.count = 2. The same rules as a file, with MAINWAVE_REDACTION_FILE=./redaction.json:
{
"blocked_values": [
{ "name": "email", "pattern": "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" },
{ "name": "api_key", "pattern": "sk-[A-Za-z0-9]{20,}" }
],
"allowed_values": ["support@example\\.com"]
}Rule packs
Every pack is off unless packs lists it. A pack rule is named <pack>.<rule>, so a replacement reads [REDACTED:secrets.aws_access_key]. Pack rules run before blocked_values, in the packs file's order (secrets, pii.contact, pii.us_gov_id, pii.payment_card) whatever order packs lists them, so a card rule never reads the digits inside a Slack token the secrets pack has replaced; a reordered list is the same configuration. And allowed_values, the ignore lists and the exempt keys apply to them as to your own rules. An unknown, repeated or misspelled pack name (names are case-sensitive) throws MainwaveConfigError.
| Pack | Matches |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| secrets | AWS access key ids; GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) and fine-grained tokens; Anthropic keys (sk-ant-); OpenAI keys, including the scoped prefixes sk-proj-, sk-svcacct-, sk-admin-, sk-None- and OpenRouter's sk-or-v1- (secrets.openai_style_scoped_key; only those prefixes follow sk-, so risk-management-... and task-runner-... are left alone, but a word ending in sk that is followed by a listed prefix still matches (flask-admin-..., task-admin-..., desk-proj-...): benign text can be over-redacted, and nothing leaks); Slack tokens (xoxa-, xoxb-, xoxc-, xoxd-, xoxp-, xoxr-, xoxs-, xapp-); PEM private-key blocks; JWTs |
| pii.contact | Email addresses, also written with %40, with an apostrophe or %2B in the local part; phone numbers in North American and international (E.164) formats, the latter with a space, dot or hyphen between digits |
| pii.us_gov_id | US Social Security numbers, with dashes, excluding the 000, 666 and 9xx area numbers no SSN uses |
| pii.payment_card | 13 to 19 digit card numbers starting 2 to 6, contiguous or in groups joined by a space, dot or hyphen (a 19-digit card in groups of 4-4-4-4-3 has its own rule); replaced only when the Luhn checksum passes |
Every quantifier in a pack is bounded, which caps the cost of scanning a hostile input. A token longer than its bound is not matched in full: a GitHub token of 256 or more characters is not matched at all, an OpenAI, Anthropic or Slack key keeps whatever follows its 256th character, a private-key block with a body over 8,192 characters, a header of more than four words or a word over 16 letters is left alone, and a JWT with a header over 1,024 or a segment over 8,192 characters keeps its tail. The distinctive token prefixes (ghp_, sk-ant-, xoxb-, eyJ, ...) carry no leading word boundary, so a token glued to a word still matches; the AWS key and the plain sk- key keep one. An E.164 number starts at a + that no word character precedes and may hold single spaces, dots or hyphens between its digits, so +442071838750 12 loses the 12 too. Because dots and hyphens may separate the digits, signed decimals, ISO 6709 coordinates and IP-like numbers (+40.7127753-074.0059728, +1.23456789, +192.168.100.200) are false positives. The packs favour precision over recall: they miss a bare ten-digit phone number, an unhyphenated SSN, parenthesized international phone formats, a card in an uncommon grouping (4-6-6, 4-4-8) and the 40-character AWS secret access key. For pii.payment_card, a 16-digit number that fails the Luhn checksum (an order number, say) is left as it is and not counted; roughly one in ten random digit strings in range passes it, so a number that passes can still be an order number. The Luhn check is a property of pack rules only: a rule of your own has no validate field and one that has it throws.
Masking function
mask is a function (value: string, key: string) => string for what rules cannot express. It is an option of init(), createSpanProcessor(), mainwave() and installInMemoryProvider(), and works with no rules at all.
import { init } from '@mainwave/sdk'
init({
redaction: { packs: ['secrets', 'pii.contact'] },
// Keep the last four digits of an account number.
mask: (value, key) => (key === 'customer.account' ? value.replace(/\d(?=\d{4})/g, '*') : value),
})- It receives each scanned string after the rules ran, once per value, with the attribute key. For a JSON-valued attribute it is called once per value token: the decoded text of each string and the lexeme of each number (
1.50, as a string), with the attribute key; never for object keys ortrue,falseandnull. For a string array, once per element. The span name arrives with the keyspan.nameand the status description withspan.status.description. - It is called for empty strings too: an empty attribute value, array element, JSON string, span name or status description.
- It is not called for the exempt or ignored keys, for a value a
blocked_key_patternsrule already replaced whole, for event names, or for numbers and booleans that are attribute values. - Its result is used as it is and is not scanned again. A result that differs from the text it received counts 1 in
mainwave.redaction.count. In a JSON value, a token whose final text changed is rewritten as a JSON string (so a number can become a string). - A throw, or a result that is not a string, drops the span (see below).
- A different function on a later call throws
MainwaveReinitConflict, as different rules do.
What is scanned. The span name, the span status description, string attribute values, each string in a string-array attribute, and the attribute values of events, span links and the span's resource. This covers prompts, completions, tool arguments and results, recordException messages and the labels an eve runAttributes hook returns. The resource is the exported copy: your own provider's resource is not changed. A value that is JSON (a strict RFC 8259 text that starts with { or [, as the message and tool-argument attributes are) is redacted in place: each string and each number value token is scanned on its own, so a card number written as a JSON number is found, and only a token whose text changed is rewritten, as a JSON string. Everything else in the value stays byte for byte: key order, duplicate keys, whitespace, number spelling and the escapes of untouched tokens. Object keys, true, false and null are never scanned. A match never spans two tokens, and JSON inside a string token is plain text. A value that is not strict JSON (a trailing comma, a NBSP before the {) is scanned as one plain string. A blocked_key_patterns hit on a JSON value writes the token as a JSON string ("[REDACTED:name]"), so the value stays valid JSON. The status description is scanned as plain text, never as JSON. blocked_key_patterns and the ignore lists apply to the keys of resource, event and link attributes as they do to span attributes; the span name and the status description have no key, so they apply to neither. The count is written to the span's attributes only, never to the resource.
What is not scanned. Event names, numbers and booleans that are attribute values (a number inside a JSON value is scanned), non-string array elements, JSON object keys and the exempt keys below.
Order against truncation. Redaction runs after the SDK has normalized the span and before any string is cut to MAINWAVE_CONTENT_MAX or MAINWAVE_MESSAGES_MAX, so a secret near a size limit is replaced whole, never cut in half. With content capture off, the content attributes are removed as usual and are not counted. Two cuts happen before the SDK sees a value:
- OpenTelemetry's attribute value length limit (
OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT,OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). In owned mode with redaction on, the SDK applies the limit after redaction instead of the provider before it. It applies it to the attributes your application recorded (span, event and link attributes), so the exported value is the redacted value cut to the limit; attributes the SDK writes itself keep their own bounds. In host mode the host's provider cuts first. With redaction ormaskon and a limit that would really cut (the first of the two variables that parses, finite and above 0), the SDK logs one warning saying so; leave the host's value length limit unset when you redact, including one set in the host's own code, which the SDK cannot see. - eve cuts its content attributes at 32,768 characters before any span processor sees them. A secret that straddles that cut leaves a prefix the rules cannot match.
Exempt keys. Attributes that carry identity and routing are never scanned, whatever the rules say: for example mainwave.ucid, mainwave.run_id, mainwave.user_email, user.email, gen_ai.agent.name, gen_ai.conversation.id, gen_ai.tool.name, gen_ai.provider.name and service.name. A rule that matches one of their values leaves it untouched, so a rule can never break the grouping of runs and use cases. Token counts are numbers and are never touched. ignored_keys and ignored_key_patterns add keys; nothing removes one. gen_ai.request.model is not exempt.
Pattern cost. Patterns run on the event loop, synchronously, with no timeout, on every scanned string. The grammar rejects the classic exponential shapes at load, each as a MainwaveConfigError naming the rule:
- A pattern is at most 4,096 characters, and groups nest at most 32 deep.
- A quantifier with no upper bound (
*,+,{n,}) cannot sit inside a group quantified more than once, for example(?:a+)+or(?:x+y){2}. A group quantified?,{0},{1}or{0,1}repeats nothing, so(?:a+)?bis fine. - An unbounded quantifier over a group needs an operand of one fixed length, its minimum length equal to its maximum.
(?:ab{2})+(always 3 characters) and(?:a|b)+care accepted;(?:a?a)+x,(?:ab?)+c,(?:a|aa)+and(?:m{2,65535})+nare rejected. Over a single character, class or escape (a+,[a-z]+,\w*) an unbounded quantifier is always accepted. - Along any chain of nested quantifiers, the product of the maximums is at most 65,535 (an unbounded maximum counts as 1):
(?:a{1,256}){1,257}is rejected and(?:x{255}){1,257}is accepted.
There are no further static cost rules, and the limits above reject known bad shapes; they do not bound a pattern's cost. Adjacent quantifiers over overlapping sets cost about O(n^(k+1)) for k of them: \w*\w*\w*\w*\w*\w*x is accepted, and on 64 characters with no x it took 9.6 s in Node (4.2 s in Python). The fixed-length rule constrains only unbounded quantifiers, so a counted quantifier over a variable-length group is accepted and can be exponential on a run of as with no x: (?:a|aa){1,1000}x took 6.9 s on 38 characters in Node (4.6 s in Python), and (?:a?a){1,40}x took 2.9 s on 36 in Node (2.0 s in Python). Bounded nesting such as (?:a{1,2}){1,30000}x, overlapping alternatives of one length such as (?:a|a)*c and a long input against a top-level a+b are accepted and can be slow too. Patterns you write are your responsibility: keep quantifiers bounded and small ({1,64} instead of + or *), avoid adjacent quantifiers over the same characters, and test a new pattern against a long input before you ship it. The built-in packs are bounded and cost-tested against 262,144-character adversarial inputs.
Pattern syntax. A pattern is checked against a fixed grammar when the SDK loads it, and anything outside the grammar is a MainwaveConfigError naming the rule. The SDK builds its own regular expression from the checked pattern and never hands your text to the engine, so one rules file means the same in the Python SDK and in this one.
| Element | Allowed |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Literals | Any character except \ ^ $ . \| ? * + ( ) [ ] { }; write those with a backslash. \/ and \- are also accepted. |
| Escapes | \d \D \w \W \s \S \b \B, \t \n \r \f \v, \xHH (exactly two hex digits) |
| Groups | (...) and (?:...), both non-capturing; alternation with \|, and an alternative may be empty |
| Quantifiers | *, +, ?, {n}, {n,}, {n,m} (counts up to 65,535; leading zeros are fine, a{01} is a{1}), each with an optional lazy ? |
| Classes | [...] and [^...] with literals, \xHH, ranges of those, and \d \D \w \W \s \S; escape a ], [ or - you mean literally |
| Anchors | ^ is the start of the text and $ is the end of the text |
What the characters mean, the same in every engine:
.is any character except\n,\r, U+2028 and U+2029.\sis tab, newline, vertical tab, form feed, carriage return and space, and nothing else: a no-break space is not\sand is matched by\S.\d,\wand\bare ASCII only, so\Wmatcheséand an emoji.$is the end of the text. It does not match before a trailing newline, and there is no multiline mode.- Matching is over characters, not UTF-16 units: an emoji is one character, and a quantifier applies to all of it.
case_insensitive: truefolds ASCII letters only and needs an ASCII pattern (a non-ASCII character, or a\xHHat or above 0x80, is rejected);knever matches the Kelvin sign.
Rejected: lookahead and lookbehind, backreferences and other digit escapes, named and atomic groups, inline flags such as (?i) (use case_insensitive), \A, \Z, \z, \p, \u and every escape not listed above, possessive quantifiers, a quantifier after a quantifier, a quantifier on ^, $ or \b, a quantifier on a group that can match zero-width, a pattern over 4,096 characters or 32 groups deep, a pattern that breaks the cost limits above, an unescaped {, } or ], a class that starts with ] or holds an unescaped [, &&, -- or ~~, a range that is reversed or has a class escape as an end, and any pattern that can match zero-width (a*, a|, \b, ^, $, (?:ab)?).
Bad rules. An unreadable or unparseable file, a set but empty MAINWAVE_REDACTION_FILE, an unknown key or pack, an unsupported pattern, a rule that does not compile or a mask that is not a function makes init(), createSpanProcessor() or mainwave() throw MainwaveConfigError naming the rule, pack or key. Nothing is exported. In a runtime with no filesystem the file source fails with that error and the option still works. A later call that passes different rules or another function throws MainwaveReinitConflict; the first call's rules stay in force. With the SDK disabled, rules and mask are neither read nor checked.
Failure while redacting. If applying the rules to a span throws, or the mask function throws or returns something that is not a string, the SDK drops that span, never exports it unredacted, and logs one error line per distinct failure: a span could not be redacted and was not exported (<step>: <error type>). The line carries neither the span's content nor the thrown error's text. The trace's other spans still export.
Testing your rules. installInMemoryProvider({ redaction, mask }) from @mainwave/sdk/testing captures the redacted spans.
Flush and serverless
flush({ timeoutMs }) sends every buffered and queued span, incomplete traces included, and resolves true when the collector accepted all of it within timeoutMs (default 30,000). It resolves false on a timeout, a failed export or a dropped trace, and never rejects. The spans of a timed-out flush keep exporting in the background.
| Host | What flushes | What the application does |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| eve on Vercel | the embedded @vercel/otel registers a flush with the request's waitUntil for each trace | nothing |
| @vercel/otel host | the same waitUntil flush | nothing |
| Owned mode on Vercel Functions | when a root span starts under a request, the SDK registers a waitUntil that resolves when that span ends (at most 30,000 ms), then flushes; a run's end registers one too | nothing; await flush() before returning is also safe |
| Owned mode on any other serverless platform (no request context) | nothing | await flush() before the handler returns |
| Long-lived process (server, worker, NodeSDK) | a 5 s batch schedule; beforeExit flushes with a 10 s timeout | await shutdown() from your own signal handler |
| SIGTERM | only when handleSignals: true or MAINWAVE_HANDLE_SIGNALS=true: flush (5 s), then the SIGTERM listeners registered before the SDK's, or exit code 143 when there were none | opt in, or call shutdown() yourself |
import { flush, init, run } from '@mainwave/sdk'
init({ useCaseKey: 'invoice-workflow' })
export async function handler(event: { invoiceId: string }): Promise<{ ok: boolean }> {
await run(event.invoiceId, async () => {
// LLM calls
})
const delivered = await flush()
return { ok: delivered }
}import { init, shutdown } from '@mainwave/sdk'
init({ useCaseKey: 'invoice-workflow' })
process.once('SIGTERM', () => {
void shutdown().finally(() => process.exit(0))
})By default no signal listener is installed and the SDK never calls process.exit. await shutdown() flushes (30,000 ms), shuts the provider (owned mode) or the processor (host mode) down, removes the SDK's beforeExit and signal listeners, resolves even when the collector fails, and is safe to call twice.
A span that ends after the last flush of an invocation and before the platform freezes the function is lost; it is not retried on the next invocation. A thawed instance exports what remained buffered, as an incomplete trace, once MAINWAVE_TRACE_BUFFER_TTL_SECONDS has passed.
Verify a span arrived
- Run the application with
MAINWAVE_LOG_LEVEL=info. After the first successful export the SDK writes one line to stderr,mainwave: exported first span batch (<n> spans) to <collector url>/v1/traces. With a derived use case it also printsmainwave: use-case identity resolved: key=… ucid=… (assign it at …/unbound-ucids?q=…). await flush()returnstrue.falsemeans a batch failed; with the default log level the SDK has already logged a warning naming the HTTP status or error code.- Open the use case in the Mainwave app: the run appears with its spans and token counts.
A trace made only of HTTP or database spans is dropped; a run(), a span() or an attached provider call keeps its trace. At the default warn level, a process with ucidNamespace set and every installed provider attached or skipped prints nothing.
Degraded states
The SDK never throws into the application after init(), except for invalid caller input. Each row below is what the SDK does instead, what it logs and what Mainwave shows.
| Condition | SDK behavior | Log | Mainwave shows |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| disabled: true or MAINWAVE_DISABLED=true | no provider, processors or listeners; run(), span() and bindIdentity() run the callback; flush() resolves true | info only | nothing |
| No API key, owned or host: init() or createSpanProcessor() | throws MainwaveConfigError | none | nothing |
| useCaseId or MAINWAVE_USE_CASE_ID is not a UUID: init() or createSpanProcessor() | throws MainwaveConfigError | none | nothing |
| No API key, or a useCaseId that is not a UUID, eve: mainwave() | returns an integration with no processors; eve runs normally | one error: eve spans are not sent to Mainwave: … | nothing |
| eve hands the SDK a span resource it cannot read | spans are sent without the SDK's resource keys | one warning: eve <version> exported a span resource the SDK cannot read; eve spans are exported without mainwave.sdk.language, mainwave.sdk.version, telemetry.sdk.name and telemetry.sdk.language | the runs, with no SDK name, language or version as their sender |
| Another tracer provider registered, createSpanProcessor() not wired into it | host mode, exports nothing | one warning naming both wiring calls | nothing |
| Host tracer provider on @opentelemetry/sdk-trace-base 1.x (@opentelemetry/sdk-node 0.57.x or older, @vercel/otel 1.x)
