neosigma
v0.11.1
Published
NeoSigma TypeScript SDK: emit product events that join your agent traces on turn_id.
Readme
neosigma
Trace your AI agents and ship the results to NeoSigma. Add a
few lines, run your agents as usual, and every run's model calls, tool calls, and
token usage lands in NeoSigma as a structured OpenTelemetry trace. Product events you
capture() join those traces on a single turn_id.
- Dark by default: with no API key (and
NEOSIGMA_CONSOLE_EXPORT=false) the SDK is a complete no-op and never touches your application's own OpenTelemetry setup, so it is safe to leave in place. - Provider-agnostic: a small core with thin adapters that wrap the agent framework you already use, with no hard dependency on any provider SDK.
Using Python? See the Python SDK.
Use cases
- Agent observability. See every model call, tool call, and token count from a run as one trace, without hand-instrumenting each call.
- Product analytics joined to agent behavior.
capture()events and agent traces share oneturn_id, so a product signal (a click, a conversion) links to the exact run behind it. - Keep your existing stack. Dual-export the same traces to another backend, and mirror PostHog or Mixpanel events, with no migration.
- One turn per HTTP request. Drop-in Express middleware and a fetch-style wrapper open and close a turn around each request (see One turn per HTTP request).
Quick start
npm install neosigmaimport {
init,
trace,
capture,
identify,
installShutdownHandlers,
} from "neosigma";
init(); // reads NEOSIGMA_API_KEY / NEOSIGMA_EVENTS_ENDPOINT; dark without a key
installShutdownHandlers(); // flush queued spans and events on SIGTERM/SIGINT
await trace({ turnId: turn.id, distinctId: userId }, async () => {
identify(userId, { plan: "pro" });
capture("message sent", { length: text.length });
});Tracing your agents
init() also configures OpenTelemetry tracing: model calls, tool calls, and your
own instrumented functions become spans, and the same turn_id used for events
above ties them together into one trace.
Without an API key and without NEOSIGMA_CONSOLE_EXPORT=true, tracing stays dark:
no OpenTelemetry TracerProvider is installed, and every span helper below runs as
a no-op.
import { init, turn } from "neosigma";
init(); // reads NEOSIGMA_API_KEY / NEOSIGMA_OTEL_ENDPOINT / NEOSIGMA_CONSOLE_EXPORT
await turn({ userMessage: question, distinctId: userId }, async (t) => {
const reply = await runAgent(question);
t.finish({ output: reply });
});Call shutdown() before process exit to flush and tear tracing down. Call it
once, at exit, not mid-run. In the default private mode a later init()
rebuilds a fresh private provider, so tracing can resume. In own mode a later
init() cannot re-own the global (OpenTelemetry sets it only once), so it falls
back to a private provider. NeoSigma tracing resumes, but no longer captures
process-global spans.
turn()
turn(opts, fn) is the canonical way to trace one user message. It opens a trace
root tied to a turnId and sessionId (minted if you do not supply them), runs
fn inside that trace, and ends the span when fn returns, or when its returned
promise settles. Anything traced inside fn, including nested turn(), tool(),
and interaction() calls, nests under this root.
A turn() called from inside another turn does not start a new trace: it reuses
the ambient turnId and sessionId and opens a child span instead.
turn({ turnId: "turn_123", sessionId: "sess_123" }, (t) => {
t.setAttributes({ plan: "pro" });
t.setContent({ prompt: "extra context" });
// ... trace tool/interaction calls here ...
});finish() runs automatically when fn returns or throws; the callback's return
value passes through to the caller and is not recorded as content (a handler
often returns a response object, not the reply text). Call t.finish({ output })
before returning to record the turn's output, or use turnHandler (below),
which records the return value via outputFrom by default.
Turns that span multiple scopes
turn() wraps one callback. For work that spans separate scopes, for example a
conversation whose turns open in different request handlers, give each scope its
own turn() and share a sessionId. One turn is one trace, and a conversation
is a session of turns, so the scopes stay correlated through the shared session
without a long-lived handle.
// first request
await turn({ sessionId, userMessage }, async (t) => {
/* ... */ t.finish({ output: reply });
});
// later request, same conversation
await turn({ sessionId, userMessage: followUp }, async (t) => {
/* ... */ t.finish({ output: reply2 });
});Routing a turn to a project
Declare a project on the turn and every span in it carries neosigma.project,
which takes precedence over the process-wide project set at init(). Use it
when one process serves several projects.
await turn({ project: "acme-support", sessionId }, async (t) => {
const reply = await runAgent(question);
t.finish({ output: reply });
});
// Or when the project is only known after the turn opens:
await turn({ sessionId }, async (t) => {
t.setProject(resolveProject(question));
});Four things worth knowing:
- A trace has one project, and the first declaration wins. A second, differing one is a conflict rather than an update, so it warns and the first one stands. This keeps a framework default from silently overwriting a project you declared yourself.
- A nested
turn()inside a trace that has no project yet declares for the whole trace, which is how you declare when a framework or middleware owns the outer turn. Inside a trace that already has one it warns and inherits. - Spans that started before the declaration stay unlabelled. They cannot be
re-stamped once open, and labelling them later would leave one trace
disagreeing with itself. The trace root is still open, so it does take the
project. Prefer
turn({ project })when you can. setAttributesrefusesneosigma.project. Routing comes from a declaration, never from forwarded metadata, so user-supplied data cannot pick the project.
Across a process or queue hop, thread the resolved project alongside the
turnId and pass it back in (trace({ turnId, project }, ...)). Pass the value
you were given rather than recomputing it on the far side, whose configuration
may differ.
Serving many projects from one HTTP service? Both middlewares take a project
and a projectResolver, described under "One turn per HTTP request" below.
An adapter that opens its own traces takes a project too, for a process that
runs several agents belonging to different projects:
for await (const message of traceClaude(query({ prompt }), { project: "agent-one" })) {
// ...
}
const client = wrapManagedAgents(anthropicClient, { project: "agent-two" });That binds the project for every trace the adapter opens, so it fits one agent per project, not one project per request. For a multi-tenant service sharing a client, declare on the enclosing turn instead. Omitted, an adapter inherits whatever turn encloses it.
Product events carry the project too. capture() stamps the ambient turn's
project, and takes an explicit { project } for a caller that knows the routing
without being inside the turn that owns it. An event emitted outside any turn
(telemetry-only usage, a wrapped analytics client, identify() at login) names
no project and resolves to the default.
AsyncLocalStorage carries the binding across await and across timers and
callbacks in the same async context, so work started inside a turn stays in its
project even when it settles after the turn returns. It does not cross a
worker_threads boundary or a process hop, so spans there are unlabelled until
you re-bind with trace({ turnId, project }).
An unlabelled span is not an error. It routes to the default project, which is recoverable in a way a wrong label is not.
tool(), interaction(), turnHandler()
tool(fn, { name })wraps a function so each call becomes anexecute_toolspan, nested under whatever span is active when it runs.interaction(fn, { name })wraps a function so each call becomes aninvoke_agentspan: a trace root by default, or a child span when called while another span (a turn, tool, or interaction) is active.turnHandler(fn, opts)wraps a request-handler-shaped function inturn().opts.sessionFrom(args)andopts.messageFrom(args)read the session id and user message from the handler's argument list.opts.outputFrom(result)reads the value to record as the turn's output; the default isString(result).
const search = tool(async (query: string) => db.search(query));
const answer = interaction(async (question: string) => {
const hits = await search(question); // nested under the interaction
return llm.complete(question, hits);
});
const handleChat = turnHandler(
async (req: ChatRequest) => llm.complete(req.message),
{
sessionFrom: (args) => (args[0] as ChatRequest).sessionId,
messageFrom: (args) => (args[0] as ChatRequest).message,
},
);Call arguments and the return value are recorded as prompt/completion content on
the span, governed by NEOSIGMA_CAPTURE_CONTENT and NEOSIGMA_MAX_CONTENT_CHARS
(see Configuration below).
One turn per HTTP request
Wrap your routes and every request opens a turn, with the turn id returned on
the x-neosigma-turn-id response header. That header is how a client attaches
feedback later. It reads the id from the response, and a thumbs-up fifteen
seconds later joins to the right agent trace.
Express. Mount it after your body parser.
import express from "express";
import { expressTurnMiddleware } from "neosigma";
const app = express();
app.use(express.json());
app.use(
expressTurnMiddleware({
messageExtractor: (req) => (req.body as { message?: string })?.message,
}),
);Next App Router, and any other handler that takes a Request and returns a
Response (Bun.serve, Cloudflare Workers, Deno).
import { withTurn } from "neosigma";
export const POST = withTurn(async (request: Request) => {
const { message } = await request.json();
return Response.json({ reply: await runAgent(message) });
});The request type is inferred from your handler, so a handler declared over
NextRequest keeps that type on the wrapped route.
Next middleware cannot host this. NextResponse.next() continues the request
rather than awaiting it, so there is no point at which the turn could close.
Wrap the route handler instead.
Hono hands its handlers a Context rather than a Request, so wrap a
fetch-shaped function and pass it the raw request.
const traced = withTurn(async (request: Request) => {
const { message } = await request.json();
return Response.json({ reply: await runAgent(message) });
});
app.post("/chat", (c) => traced(c.req.raw));Both adapters read the session and distinct ids from request headers,
x-neosigma-session-id and x-neosigma-distinct-id by default, configurable
via the sessionHeader and distinctHeader options. A pathFilter option
skips paths you do not want opening a turn, such as health checks, and
withTurn also takes an outputFrom option to derive the turn's recorded
output from the response.
Serving several projects from one service? The middleware owns the turn, so route it there.
app.use(
expressTurnMiddleware({
project: "acme-support", // one project for the whole service
projectResolver: (req) => headerValue(req.headers, "x-tenant"), // or per request
}),
);projectResolver takes precedence over the static project, and falls back to
it when the resolver returns "" or throws. A failing tenant lookup costs the
label, never the request. Leave both unset and a handler nested inside can still
declare with turn({ project }), which lands on the middleware's turn.
Supported versions
| Adapter | Supported |
| ----------------------- | -------------------------------------------------------------- |
| expressTurnMiddleware | Express 5 |
| withTurn | Next.js 15 App Router, Bun 1.3, Cloudflare Workers, Deno, Hono |
Behaviour
- A response of 500 or above marks the turn errored. On Express 4 an
asynchandler that rejects never reaches your error handler, so no 500 is produced and the turn is not marked errored. Callnext(err)yourself on Express 4. - Mounted inside a router,
pathFilterreceives the path relative to the mount point. Underapp.use("/api", router)it sees/chat, not/api/chat. - Websocket upgrades pass through untouched and carry no turn id header.
- Neither adapter can break your route. If anything goes wrong it returns your handler's own response, and the turn is still recorded.
How it joins
turn_id is the durable join key back to the agent's spans. Bind it once with
trace({ turnId }); every capture() inside that scope carries it, so product
events sit next to the model's spans for the same turn in ClickHouse. The ingest
resolves your API key to an org_id server-side (the client never sends it).
Uploading generated files
Call uploadArtifact() inside the span that produced a local file or in-memory
data. It uses the active turn's project, or the project passed to init(),
uploads the data to NeoSigma storage, and records the completed file on that
exact span.
import { turn, uploadArtifact } from "neosigma";
await turn({ project: "acme-support", sessionId }, async () => {
const reportPath = await buildReport();
const uploaded = await uploadArtifact(reportPath, {
name: "Support report.csv",
});
console.log(uploaded.location);
});In-memory data uses the same operation and requires a filename:
const uploaded = await uploadArtifact(Buffer.from("report contents"), {
name: "Support report.txt",
});uploadArtifact(data, { name?, contentType? }) is available in Node.js only.
For paths, the display name defaults to the file name. In-memory data requires
name. Common content types are inferred from the filename. Unlike the
fail-open artifact(location, name)
metadata helper, this explicit operation rejects with ArtifactUploadError if
validation, storage, or span recording fails. Nothing is recorded on the span
until the upload completes.
To copy a file from a short-lived signed HTTPS URL, use
uploadArtifactFromUrl(). The SDK streams the source directly into NeoSigma
storage; the source URL is not sent to NeoSigma or recorded on the span.
import { uploadArtifactFromUrl } from "neosigma";
const uploaded = await uploadArtifactFromUrl(signedUrl, {
name: "Support report.csv",
});This operation is also Node.js only. The source must return HTTP 200 with a
non-zero Content-Length and an unencoded body, and redirects are rejected.
name must be a single filename of at most 256 characters.
Adapters
Thin wraps that trace an agent framework or client library you already use, feeding
the same span contract turn() / tool() / interaction() produce.
- Claude Agent SDK:
traceClaude(stream)traces aquery(...)run;wrapClaudeQuery(query)wraps the SDK's ownqueryfunction as a drop-in replacement. - Anthropic Managed Agents:
wrapManagedAgents(client)traces a session's model and tool calls. - Vercel AI SDK:
wrapAISDK(ai)turns on the AI SDK's own native OpenTelemetry export forgenerateText/streamText/generateObject/streamObject. - LangChain:
neosigmaCallbackHandler()traces a chain, agent, or model run's LLM, tool, and chain calls through LangChain's own callback system. - PostHog / Mixpanel:
wrapPosthog(client)/wrapMixpanel(client)mirror your existing analytics calls into NeoSigma. - Auto-instrumentation:
init({ tracingEnabled: true })turns on off-the-shelf instrumentors for raw LLM clients (Anthropic, OpenAI) and for LangChain, no per-call code.
Claude Agent SDK
import { query } from "@anthropic-ai/claude-agent-sdk";
import { traceClaude } from "neosigma";
for await (const message of traceClaude(query({ prompt: "..." }))) {
// messages pass through unchanged; NeoSigma emits a turn/chat/tool trace alongside
}Or wrap query once and use the result as a drop-in replacement everywhere:
import { query } from "@anthropic-ai/claude-agent-sdk";
import { wrapClaudeQuery } from "neosigma";
const tracedQuery = wrapClaudeQuery(query);
for await (const message of tracedQuery({ prompt: "..." })) {
// ...
}Anthropic Managed Agents
import Anthropic from "@anthropic-ai/sdk";
import { init, shutdown, wrapManagedAgents } from "neosigma";
init();
const client = wrapManagedAgents(new Anthropic());
// Build and run a Managed Agents session as you normally would. Streaming the
// session produces one NeoSigma trace: model calls, tool calls, and token usage.
const session = await client.beta.sessions.create({
agent,
environment_id: environment.id,
});
const stream = client.beta.sessions.events.stream({ session_id: session.id });
for await (const event of stream) {
// ...
}
await shutdown();wrapManagedAgents returns a proxy typed as your original client (like Python's
cast(ClientT, ...)), so .stream() keeps that client's own declared return type
statically. At runtime the returned value is directly async-iterable (no await
needed before for await), so if your client's declared type still shows a
Promise of a stream, cast it: stream as unknown as AsyncIterable<unknown>.
Vercel AI SDK
The AI SDK emits gen_ai.*-shaped OpenTelemetry spans natively for
generateText, streamText, generateObject, and streamObject. NeoSigma does
not synthesize its own spans for it. On ai@7 that span collection lives in
@ai-sdk/otel, so install it. wrapAISDK then enables the AI SDK's telemetry
per call and injects NeoSigma's own tracer, so the spans reach NeoSigma under the
default private provider without owning the global:
import * as ai from "ai";
import { init, wrapAISDK } from "neosigma"; // also: npm install @ai-sdk/otel
init();
const { generateText, streamText } = wrapAISDK(ai);
await generateText({ model, prompt: "..." }); // telemetry on by default, routed to NeoSigmaAn explicit experimental_telemetry at a call site always wins, including
{ isEnabled: false } to opt that one call back out. Setting
experimental_telemetry: { isEnabled: true } by hand, without wrapAISDK, does
not route the AI SDK's spans to NeoSigma under the default private provider.
Some AI SDK versions emit their token usage and model id under legacy
ai.usage.* / ai.model.id keys instead of gen_ai.*. init()'s tracing
pipeline fills the canonical gen_ai.* key from whichever legacy key is
present, only when the canonical key is not already set, so nothing is ever
overwritten or duplicated in your own dashboards.
LangChain
neosigmaCallbackHandler() returns a BaseCallbackHandler-shaped object: pass it
in a callbacks array anywhere LangChain (JS) accepts one, a chain, agent, or
model constructor, or a single .invoke() call, and every LLM call, tool call,
and chain becomes a chat, execute_tool, or structural span.
import { ChatOpenAI } from "@langchain/openai";
import { init, neosigmaCallbackHandler } from "neosigma";
init();
const model = new ChatOpenAI({ callbacks: [neosigmaCallbackHandler()] });
await model.invoke("hello");One handler instance can be reused across invocations, including concurrent ones: LangChain mints a fresh run id per call, so each run's span is looked up by that id rather than by handler identity. A run whose End/Error is never delivered is cleaned up by a bounded backstop rather than kept forever.
LangChain nests spans following its own run/parent-run tree, parenting under
whichever NeoSigma span (a turn(), or another traced run) is already active.
A top-level LangChain run does not open its own turn, so wrap it in turn()
yourself when it is the start of a user exchange.
Prefer not to touch your model or chain construction at all? init({
tracingEnabled: true }) also turns on @traceloop/instrumentation-langchain
(see Auto-instrumentation below) for a zero-code path, no
neosigmaCallbackHandler() needed.
Already using PostHog or Mixpanel?
If your product is already instrumented with PostHog or Mixpanel, you do not need
to re-instrument. Wrap the client once and every event you already send also flows
into NeoSigma, sharing the same turn_id spine. Your existing provider keeps
receiving every event unchanged (this mirrors, it does not redirect):
import { PostHog } from "posthog-node";
import { init, wrapPosthog } from "neosigma";
init();
const ph = wrapPosthog(new PostHog(apiKey)); // the posthog-node client
// Use it exactly as before. Each call ALSO reaches NeoSigma.
ph.capture({
distinctId: "user_123",
event: "rewind_clicked",
properties: { surface: "chat" },
});
ph.identify({ distinctId: "user_123", properties: { plan: "pro" } });Mixpanel works the same way via wrapMixpanel(new Mixpanel(token)): track(...)
mirrors to capture(), people.set(...) to identify(). Both wraps are
transparent (every other attribute/method delegates unchanged), duck-typed (the
SDK never imports posthog-node / mixpanel, so no new dependency), and
fail-open (the mirror is best-effort and can never break your analytics call). An
event fired inside a trace() / turn() scope joins that agent trace on
turn_id; one fired outside is still a valid event, joinable by distinct_id.
Auto-instrumentation: Anthropic, OpenAI, and LangChain clients
init({ tracingEnabled: true }) turns on off-the-shelf Traceloop (OpenLLMetry)
instrumentors, pointed at NeoSigma's own tracer provider, so raw LLM client calls
and LangChain runs become spans with no per-call code:
import { initAsync } from "neosigma";
await initAsync({ tracingEnabled: true }); // wait until module hooks are ready
const { default: Anthropic } = await import("@anthropic-ai/sdk");
const client = new Anthropic();
await client.messages.create({ model: "claude-opus-4-8", messages: [...] }); // now a traced spanInitialize before loading the provider client. A bootstrap dynamic import as
above is the most reliable startup order; initAsync() also applies an
instrumentor-provided fallback when a supported provider is already loaded.
Each provider's instrumentor (@traceloop/instrumentation-anthropic,
@traceloop/instrumentation-openai, @traceloop/instrumentation-langchain) is
an optional peer dependency: install the one(s) you need alongside the
provider's own package (@anthropic-ai/sdk, openai, or @langchain/core),
for example npm install @traceloop/instrumentation-anthropic. If a provider
package is installed but its instrumentor is not, init() logs a warning and
skips it rather than breaking your process.
For a Next.js standalone/server deployment, keep the provider and instrumentor as server externals and reference the instrumentor with a literal import so Next's output tracer copies its runtime dependency graph:
// next.config.js
module.exports = {
output: "standalone",
serverExternalPackages: ["openai", "@traceloop/instrumentation-openai"],
};// instrumentation.js
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("@traceloop/instrumentation-openai");
const { initAsync } = require("neosigma");
await initAsync({ tracingEnabled: true });
}
}Provider posture and coexistence
init() needs an OpenTelemetry TracerProvider to emit spans. By default it
builds a private provider (NeoSigma's own provider, never registered as the
process global). NeoSigma runs alongside an existing OpenTelemetry setup
(Sentry, Datadog, LangSmith, opentelemetry-instrument) with no change to that
tool's configuration, and without capturing that tool's spans. Both SDKs share
this default.
A private provider isolates NeoSigma's span processing and export from the
host's. Each turn() opens its own trace with a fresh trace id (one trace per
turn), so an agent trace is not grafted onto the host's trace tree. Correlation
is by attribute. turn_id and session_id tie an agent's spans to your product
events and group a conversation's turns, independent of trace ids.
Two non-default postures:
- Own the global. Set
init({ privateProvider: false })(orNEOSIGMA_PRIVATE_PROVIDER=false) to register NeoSigma's provider as the process global and capture every OpenTelemetry span in the process, including spans from other instrumented libraries. If another provider already owns the global, NeoSigma falls back to a private provider and leaves it untouched. - Hand NeoSigma a provider you built. Pass
init({ tracerProvider })to route NeoSigma's spans through aTracerProvideryou construct yourself, for example one that fans out to several backends (see Dual export below). Construct it with bothCorrelationSpanProcessorandNeoSigmaSpanProcessorin itsspanProcessors. OpenTelemetry JS 2.x accepts span processors only at construction, so NeoSigma cannot add them to a provider it did not build, and if either is missing, spans are created but never reach NeoSigma.
Without an API key, and without NEOSIGMA_CONSOLE_EXPORT=true, the default
private posture and own mode install no provider, so every span helper is a
no-op. The tracerProvider handoff is the exception. As long as the SDK is
enabled it emits through the caller's provider even without a NeoSigma key,
though spans reach NeoSigma only if that provider carries
NeoSigmaSpanProcessor.
Configuration
| Env var | Default | Purpose |
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| NEOSIGMA_API_KEY | (none) | API key. Without it the SDK stays dark (a no-op). |
| NEOSIGMA_EVENTS_ENDPOINT | https://otel.neosigma.ai/v1/events | Where events are POSTed. |
| NEOSIGMA_ENABLED | true | Master switch. Set false to disable. |
| NEOSIGMA_TRACING_ENABLED | false | Auto-instrument detected LLM clients when their matching optional Traceloop instrumentor is installed. |
| NEOSIGMA_OTEL_ENDPOINT | https://otel.neosigma.ai/v1/traces | Where spans are exported, over OTLP/HTTP. |
| NEOSIGMA_MANAGED_AGENTS_BASE_URL | https://api.neosigma.ai | NeoSigma API origin used for managed file uploads. |
| NEOSIGMA_CONSOLE_EXPORT | false | Also print spans to stdout. Set to true with no API key to turn tracing on for local debugging. |
| NEOSIGMA_PRIVATE_PROVIDER | true | Build a private provider (the default) instead of owning the global. Set false to own the global and capture all process spans. |
| NEOSIGMA_CAPTURE_CONTENT | true | Record prompt/completion text on spans. Set to false to omit it. |
| NEOSIGMA_MAX_CONTENT_CHARS | 1000000 | Safety cap per captured field. Text keeps its start and end, and JSON remains valid. 0 disables truncation. |
Pass the same values to init({ apiKey, eventsEndpoint, enabled, otelEndpoint,
consoleExport, privateProvider }) to override the environment, or
init({ settings: { ... } }) to override any other Settings field (for example
captureContent or maxContentChars) with no dedicated top-level option.
init() also accepts the live objects extraSpanProcessors and tracerProvider
(see Provider posture and Dual export).
Dual export: send to NeoSigma and another backend
NeoSigma is built on OpenTelemetry, so the same spans can go to NeoSigma and to another backend at once.
The simplest shape: add the other backend's span processor to the provider
NeoSigma builds, via extraSpanProcessors. This works in the default private
posture and in own mode. Everything this SDK emits (turn(), the wrappers, the
adapters, auto-instrumented clients) then reaches both backends. OpenTelemetry JS
2.x providers accept span processors only at construction, so this is how you add
a second backend rather than a post-init addSpanProcessor call.
import { init } from "neosigma";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
init({
apiKey: "ns_live_...",
extraSpanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: "https://api.smith.langchain.com/otel/v1/traces",
headers: { "x-api-key": process.env.LANGSMITH_API_KEY! },
}),
),
],
});The extra processors ride NeoSigma's provider, so flush() and shutdown()
cover them too. In the default private posture this exports your agent trace
(what NeoSigma instruments) to both backends. To also hand another backend the
host's non-agent spans, use own mode or the tracerProvider handoff below.
To route everything through one provider you own instead, build it with
CorrelationSpanProcessor and NeoSigmaSpanProcessor alongside your other
backend's processor, and hand it to init({ tracerProvider }):
import {
init,
CorrelationSpanProcessor,
NeoSigmaSpanProcessor,
} from "neosigma";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
const provider = new NodeTracerProvider({
spanProcessors: [
new CorrelationSpanProcessor(),
new NeoSigmaSpanProcessor(process.env.NEOSIGMA_API_KEY!),
// your other backend's span processor
],
});
init({ tracerProvider: provider });NeoSigmaSpanProcessor batches spans and exports them to NeoSigma's ingest over
OTLP/HTTP with your API key attached as a request header. It accepts the same
endpoint, maxQueueSize, maxExportBatchSize, scheduleDelayMillis, and
exportTimeoutMillis options as init()'s equivalent settings. The
init({ tracerProvider }) handoff above wires up NeoSigma's native helpers and
product-event sink; adding NeoSigmaSpanProcessor to a provider without calling
init() exports spans but does not.
Bulk import and export
Move traces in bulk: pull historical traces in from another provider, or pull your
own NeoSigma traces out. Both return a job handle you can poll with .wait().
import { importTraces, exportTraces } from "neosigma";
const job = await importTraces("langsmith", {
destination: "my-neosigma-project",
sourceProject: "my-langsmith-project",
});
await job.wait();
console.log(job.status, job.spansDone);
const exportJob = await exportTraces({ project: "my-neosigma-project" });
await exportJob.wait();
console.log(exportJob.downloadUrl);importTraces(source, { destination, sourceProject?, since?, until? }) starts a
bulk import from source (an opaque string, for example "langsmith" or
"braintrust"). destination is the NeoSigma project the imported traces land in
and is required. sourceProject is the provider's own project to pull from, a
separate thing from destination. exportTraces({ project?, since?, until? })
starts a bulk export of your own traces; here project is your NeoSigma project to
export from. Both accept since/until as Date objects or ISO-8601 strings, and
resolve to a pending job. Call .wait({ timeoutMs }) to poll until the job reaches a
terminal status (complete, failed, or cancelled, read from job.status), or
.refresh() to poll once. getImportJob(id) and getExportJob(id) re-fetch a handle
by id.
These calls use your NeoSigma API key (the same one init() reads), and the import
source plus filters are validated server-side. Errors surface as
NeosigmaAPIError (with NeosigmaAuthError, NeosigmaJobNotFoundError, and
NeosigmaValidationError subclasses), NeosigmaConfigError, or, past a .wait()
budget, NeosigmaTimeoutError.
Guarantees
- Fail-open. Telemetry never throws into your code;
capture()is non-blocking. Network errors are swallowed and counted; a dead key disables the sink rather than hammering the ingest. - Per-request identity. Correlation ids live in
AsyncLocalStorage, so concurrent requests never leak ids into each other. Give each distinct actor its owntrace()scope. - Scalar properties. Property values are coerced to strings, finite numbers,
or booleans at the boundary, so a stray
nullor nested object can never reject a batch.
Correlation across processes
AsyncLocalStorage does not cross a queue or process hop. When you hand work to
another service or a background job, pass the turn id in the payload and re-bind it
on the far side with trace({ turnId }) (the Python SDK follows the same rule).
