npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 one turn_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 neosigma
import {
  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.
  • setAttributes refuses neosigma.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 an execute_tool span, nested under whatever span is active when it runs.
  • interaction(fn, { name }) wraps a function so each call becomes an invoke_agent span: 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 in turn(). opts.sessionFrom(args) and opts.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 is String(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 async handler that rejects never reaches your error handler, so no 500 is produced and the turn is not marked errored. Call next(err) yourself on Express 4.
  • Mounted inside a router, pathFilter receives the path relative to the mount point. Under app.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 a query(...) run; wrapClaudeQuery(query) wraps the SDK's own query function 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 for generateText/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 NeoSigma

An 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 span

Initialize 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 }) (or NEOSIGMA_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 a TracerProvider you construct yourself, for example one that fans out to several backends (see Dual export below). Construct it with both CorrelationSpanProcessor and NeoSigmaSpanProcessor in its spanProcessors. 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 own trace() scope.
  • Scalar properties. Property values are coerced to strings, finite numbers, or booleans at the boundary, so a stray null or 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).